Merge remote-tracking branch 'remotes/origin/main' into twitch-linking
This commit is contained in:
commit
6a900205c0
|
@ -73,6 +73,7 @@ export const PROD_CONFIG: EnvConfig = {
|
||||||
'manticmarkets@gmail.com', // Manifold
|
'manticmarkets@gmail.com', // Manifold
|
||||||
'iansphilips@gmail.com', // Ian
|
'iansphilips@gmail.com', // Ian
|
||||||
'd4vidchee@gmail.com', // D4vid
|
'd4vidchee@gmail.com', // D4vid
|
||||||
|
'federicoruizcassarino@gmail.com', // Fede
|
||||||
],
|
],
|
||||||
visibility: 'PUBLIC',
|
visibility: 'PUBLIC',
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,18 @@ export type Group = {
|
||||||
aboutPostId?: string
|
aboutPostId?: string
|
||||||
chatDisabled?: boolean
|
chatDisabled?: boolean
|
||||||
mostRecentContractAddedTime?: number
|
mostRecentContractAddedTime?: number
|
||||||
|
cachedLeaderboard?: {
|
||||||
|
topTraders: {
|
||||||
|
userId: string
|
||||||
|
score: number
|
||||||
|
}[]
|
||||||
|
topCreators: {
|
||||||
|
userId: string
|
||||||
|
score: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MAX_GROUP_NAME_LENGTH = 75
|
export const MAX_GROUP_NAME_LENGTH = 75
|
||||||
export const MAX_ABOUT_LENGTH = 140
|
export const MAX_ABOUT_LENGTH = 140
|
||||||
export const MAX_ID_LENGTH = 60
|
export const MAX_ID_LENGTH = 60
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { notification_subscription_types, PrivateUser } from './user'
|
||||||
|
import { DOMAIN } from './envs/constants'
|
||||||
|
|
||||||
export type Notification = {
|
export type Notification = {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
|
@ -51,28 +54,106 @@ export type notification_source_update_types =
|
||||||
| 'deleted'
|
| 'deleted'
|
||||||
| 'closed'
|
| 'closed'
|
||||||
|
|
||||||
|
/* Optional - if possible use a keyof notification_subscription_types */
|
||||||
export type notification_reason_types =
|
export type notification_reason_types =
|
||||||
| 'tagged_user'
|
| 'tagged_user'
|
||||||
| 'on_users_contract'
|
|
||||||
| 'on_contract_with_users_shares_in'
|
|
||||||
| 'on_contract_with_users_shares_out'
|
|
||||||
| 'on_contract_with_users_answer'
|
|
||||||
| 'on_contract_with_users_comment'
|
|
||||||
| 'reply_to_users_answer'
|
|
||||||
| 'reply_to_users_comment'
|
|
||||||
| 'on_new_follow'
|
| 'on_new_follow'
|
||||||
| 'you_follow_user'
|
| 'contract_from_followed_user'
|
||||||
| 'added_you_to_group'
|
|
||||||
| 'you_referred_user'
|
| 'you_referred_user'
|
||||||
| 'user_joined_to_bet_on_your_market'
|
| 'user_joined_to_bet_on_your_market'
|
||||||
| 'unique_bettors_on_your_contract'
|
| 'unique_bettors_on_your_contract'
|
||||||
| 'on_group_you_are_member_of'
|
|
||||||
| 'tip_received'
|
| 'tip_received'
|
||||||
| 'bet_fill'
|
| 'bet_fill'
|
||||||
| 'user_joined_from_your_group_invite'
|
| 'user_joined_from_your_group_invite'
|
||||||
| 'challenge_accepted'
|
| 'challenge_accepted'
|
||||||
| 'betting_streak_incremented'
|
| 'betting_streak_incremented'
|
||||||
| 'loan_income'
|
| 'loan_income'
|
||||||
| 'you_follow_contract'
|
|
||||||
| 'liked_your_contract'
|
|
||||||
| 'liked_and_tipped_your_contract'
|
| 'liked_and_tipped_your_contract'
|
||||||
|
| 'comment_on_your_contract'
|
||||||
|
| 'answer_on_your_contract'
|
||||||
|
| 'comment_on_contract_you_follow'
|
||||||
|
| 'answer_on_contract_you_follow'
|
||||||
|
| 'update_on_contract_you_follow'
|
||||||
|
| 'resolution_on_contract_you_follow'
|
||||||
|
| 'comment_on_contract_with_users_shares_in'
|
||||||
|
| 'answer_on_contract_with_users_shares_in'
|
||||||
|
| 'update_on_contract_with_users_shares_in'
|
||||||
|
| 'resolution_on_contract_with_users_shares_in'
|
||||||
|
| 'comment_on_contract_with_users_answer'
|
||||||
|
| 'update_on_contract_with_users_answer'
|
||||||
|
| 'resolution_on_contract_with_users_answer'
|
||||||
|
| 'answer_on_contract_with_users_answer'
|
||||||
|
| 'comment_on_contract_with_users_comment'
|
||||||
|
| 'answer_on_contract_with_users_comment'
|
||||||
|
| 'update_on_contract_with_users_comment'
|
||||||
|
| 'resolution_on_contract_with_users_comment'
|
||||||
|
| 'reply_to_users_answer'
|
||||||
|
| 'reply_to_users_comment'
|
||||||
|
| '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',
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -13,7 +13,6 @@ import { addObjects } from './util/object'
|
||||||
export const getDpmCancelPayouts = (contract: DPMContract, bets: Bet[]) => {
|
export const getDpmCancelPayouts = (contract: DPMContract, bets: Bet[]) => {
|
||||||
const { pool } = contract
|
const { pool } = contract
|
||||||
const poolTotal = sum(Object.values(pool))
|
const poolTotal = sum(Object.values(pool))
|
||||||
console.log('resolved N/A, pool M$', poolTotal)
|
|
||||||
|
|
||||||
const betSum = sumBy(bets, (b) => b.amount)
|
const betSum = sumBy(bets, (b) => b.amount)
|
||||||
|
|
||||||
|
@ -58,17 +57,6 @@ export const getDpmStandardPayouts = (
|
||||||
liquidityFee: 0,
|
liquidityFee: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(
|
|
||||||
'resolved',
|
|
||||||
outcome,
|
|
||||||
'pool',
|
|
||||||
poolTotal,
|
|
||||||
'profits',
|
|
||||||
profits,
|
|
||||||
'creator fee',
|
|
||||||
creatorFee
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
|
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
|
||||||
creatorPayout: creatorFee,
|
creatorPayout: creatorFee,
|
||||||
|
@ -110,17 +98,6 @@ export const getNumericDpmPayouts = (
|
||||||
liquidityFee: 0,
|
liquidityFee: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(
|
|
||||||
'resolved numeric bucket: ',
|
|
||||||
outcome,
|
|
||||||
'pool',
|
|
||||||
poolTotal,
|
|
||||||
'profits',
|
|
||||||
profits,
|
|
||||||
'creator fee',
|
|
||||||
creatorFee
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
|
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
|
||||||
creatorPayout: creatorFee,
|
creatorPayout: creatorFee,
|
||||||
|
@ -163,17 +140,6 @@ export const getDpmMktPayouts = (
|
||||||
liquidityFee: 0,
|
liquidityFee: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(
|
|
||||||
'resolved MKT',
|
|
||||||
p,
|
|
||||||
'pool',
|
|
||||||
pool,
|
|
||||||
'profits',
|
|
||||||
profits,
|
|
||||||
'creator fee',
|
|
||||||
creatorFee
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
|
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
|
||||||
creatorPayout: creatorFee,
|
creatorPayout: creatorFee,
|
||||||
|
@ -216,16 +182,6 @@ export const getPayoutsMultiOutcome = (
|
||||||
liquidityFee: 0,
|
liquidityFee: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(
|
|
||||||
'resolved',
|
|
||||||
resolutions,
|
|
||||||
'pool',
|
|
||||||
poolTotal,
|
|
||||||
'profits',
|
|
||||||
profits,
|
|
||||||
'creator fee',
|
|
||||||
creatorFee
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
|
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
|
||||||
creatorPayout: creatorFee,
|
creatorPayout: creatorFee,
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { sum } from 'lodash'
|
|
||||||
|
|
||||||
import { Bet } from './bet'
|
import { Bet } from './bet'
|
||||||
import { getProbability } from './calculate'
|
import { getProbability } from './calculate'
|
||||||
|
@ -43,18 +42,6 @@ export const getStandardFixedPayouts = (
|
||||||
|
|
||||||
const { collectedFees } = contract
|
const { collectedFees } = contract
|
||||||
const creatorPayout = collectedFees.creatorFee
|
const creatorPayout = collectedFees.creatorFee
|
||||||
|
|
||||||
console.log(
|
|
||||||
'resolved',
|
|
||||||
outcome,
|
|
||||||
'pool',
|
|
||||||
contract.pool[outcome],
|
|
||||||
'payouts',
|
|
||||||
sum(payouts),
|
|
||||||
'creator fee',
|
|
||||||
creatorPayout
|
|
||||||
)
|
|
||||||
|
|
||||||
const liquidityPayouts = getLiquidityPoolPayouts(
|
const liquidityPayouts = getLiquidityPoolPayouts(
|
||||||
contract,
|
contract,
|
||||||
outcome,
|
outcome,
|
||||||
|
@ -98,18 +85,6 @@ export const getMktFixedPayouts = (
|
||||||
|
|
||||||
const { collectedFees } = contract
|
const { collectedFees } = contract
|
||||||
const creatorPayout = collectedFees.creatorFee
|
const creatorPayout = collectedFees.creatorFee
|
||||||
|
|
||||||
console.log(
|
|
||||||
'resolved PROB',
|
|
||||||
p,
|
|
||||||
'pool',
|
|
||||||
p * contract.pool.YES + (1 - p) * contract.pool.NO,
|
|
||||||
'payouts',
|
|
||||||
sum(payouts),
|
|
||||||
'creator fee',
|
|
||||||
creatorPayout
|
|
||||||
)
|
|
||||||
|
|
||||||
const liquidityPayouts = getLiquidityPoolProbPayouts(contract, p, liquidities)
|
const liquidityPayouts = getLiquidityPoolProbPayouts(contract, p, liquidities)
|
||||||
|
|
||||||
return { payouts, creatorPayout, liquidityPayouts, collectedFees }
|
return { payouts, creatorPayout, liquidityPayouts, collectedFees }
|
||||||
|
|
|
@ -13,7 +13,10 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
|
||||||
const yesShares = sumBy(yesBets, (b) => b.shares)
|
const yesShares = sumBy(yesBets, (b) => b.shares)
|
||||||
const noShares = sumBy(noBets, (b) => b.shares)
|
const noShares = sumBy(noBets, (b) => b.shares)
|
||||||
const shares = Math.max(Math.min(yesShares, noShares), 0)
|
const shares = Math.max(Math.min(yesShares, noShares), 0)
|
||||||
const soldFrac = shares > 0 ? Math.min(yesShares, noShares) / shares : 0
|
const soldFrac =
|
||||||
|
shares > 0
|
||||||
|
? Math.min(yesShares, noShares) / Math.max(yesShares, noShares)
|
||||||
|
: 0
|
||||||
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
|
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
|
||||||
const loanPayment = loanAmount * soldFrac
|
const loanPayment = loanAmount * soldFrac
|
||||||
const netAmount = shares - loanPayment
|
const netAmount = shares - loanPayment
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { groupBy, sumBy, mapValues, partition } from 'lodash'
|
import { groupBy, sumBy, mapValues } from 'lodash'
|
||||||
|
|
||||||
import { Bet } from './bet'
|
import { Bet } from './bet'
|
||||||
|
import { getContractBetMetrics } from './calculate'
|
||||||
import { Contract } from './contract'
|
import { Contract } from './contract'
|
||||||
import { getPayouts } from './payouts'
|
|
||||||
|
|
||||||
export function scoreCreators(contracts: Contract[]) {
|
export function scoreCreators(contracts: Contract[]) {
|
||||||
const creatorScore = mapValues(
|
const creatorScore = mapValues(
|
||||||
|
@ -30,46 +30,8 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
|
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
|
||||||
const { resolution } = contract
|
const betsByUser = groupBy(bets, bet => bet.userId)
|
||||||
const resolutionProb =
|
return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit)
|
||||||
contract.outcomeType == 'BINARY'
|
|
||||||
? contract.resolutionProbability
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const [closedBets, openBets] = partition(
|
|
||||||
bets,
|
|
||||||
(bet) => bet.isSold || bet.sale
|
|
||||||
)
|
|
||||||
const { payouts: resolvePayouts } = getPayouts(
|
|
||||||
resolution as string,
|
|
||||||
contract,
|
|
||||||
openBets,
|
|
||||||
[],
|
|
||||||
{},
|
|
||||||
resolutionProb
|
|
||||||
)
|
|
||||||
|
|
||||||
const salePayouts = closedBets.map((bet) => {
|
|
||||||
const { userId, sale } = bet
|
|
||||||
return { userId, payout: sale ? sale.amount : 0 }
|
|
||||||
})
|
|
||||||
|
|
||||||
const investments = bets
|
|
||||||
.filter((bet) => !bet.sale)
|
|
||||||
.map((bet) => {
|
|
||||||
const { userId, amount, loanAmount } = bet
|
|
||||||
const payout = -amount - (loanAmount ?? 0)
|
|
||||||
return { userId, payout }
|
|
||||||
})
|
|
||||||
|
|
||||||
const netPayouts = [...resolvePayouts, ...salePayouts, ...investments]
|
|
||||||
|
|
||||||
const userScore = mapValues(
|
|
||||||
groupBy(netPayouts, (payout) => payout.userId),
|
|
||||||
(payouts) => sumBy(payouts, ({ payout }) => payout)
|
|
||||||
)
|
|
||||||
|
|
||||||
return userScore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addUserScores(
|
export function addUserScores(
|
||||||
|
|
192
common/user.ts
192
common/user.ts
|
@ -1,3 +1,5 @@
|
||||||
|
import { filterDefined } from './util/array'
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string
|
id: string
|
||||||
createdTime: number
|
createdTime: number
|
||||||
|
@ -34,7 +36,7 @@ export type User = {
|
||||||
followerCountCached: number
|
followerCountCached: number
|
||||||
|
|
||||||
followedCategories?: string[]
|
followedCategories?: string[]
|
||||||
homeSections?: { visible: string[]; hidden: string[] }
|
homeSections?: string[]
|
||||||
|
|
||||||
referredByUserId?: string
|
referredByUserId?: string
|
||||||
referredByContractId?: string
|
referredByContractId?: string
|
||||||
|
@ -63,7 +65,9 @@ export type PrivateUser = {
|
||||||
initialDeviceToken?: string
|
initialDeviceToken?: string
|
||||||
initialIpAddress?: string
|
initialIpAddress?: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
|
/** @deprecated - use notificationSubscriptionTypes */
|
||||||
notificationPreferences?: notification_subscribe_types
|
notificationPreferences?: notification_subscribe_types
|
||||||
|
notificationSubscriptionTypes: notification_subscription_types
|
||||||
twitchInfo?: {
|
twitchInfo?: {
|
||||||
twitchName: string
|
twitchName: string
|
||||||
controlToken: string
|
controlToken: string
|
||||||
|
@ -71,6 +75,55 @@ export type PrivateUser = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 notification_subscribe_types = 'all' | 'less' | 'none'
|
||||||
|
|
||||||
export type PortfolioMetrics = {
|
export type PortfolioMetrics = {
|
||||||
|
@ -83,3 +136,140 @@ export type PortfolioMetrics = {
|
||||||
|
|
||||||
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
|
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
|
||||||
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
||||||
|
|
||||||
|
export const getDefaultNotificationSettings = (
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -23,8 +23,15 @@ import { Link } from '@tiptap/extension-link'
|
||||||
import { Mention } from '@tiptap/extension-mention'
|
import { Mention } from '@tiptap/extension-mention'
|
||||||
import Iframe from './tiptap-iframe'
|
import Iframe from './tiptap-iframe'
|
||||||
import TiptapTweet from './tiptap-tweet-type'
|
import TiptapTweet from './tiptap-tweet-type'
|
||||||
|
import { find } from 'linkifyjs'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
|
|
||||||
|
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
|
||||||
|
export function getUrl(text: string) {
|
||||||
|
const results = find(text, 'url')
|
||||||
|
return results.length ? results[0].href : null
|
||||||
|
}
|
||||||
|
|
||||||
export function parseTags(text: string) {
|
export function parseTags(text: string) {
|
||||||
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
||||||
const matches = (text.match(regex) || []).map((match) =>
|
const matches = (text.match(regex) || []).map((match) =>
|
||||||
|
|
|
@ -60,23 +60,27 @@ Parameters:
|
||||||
|
|
||||||
Requires no authorization.
|
Requires no authorization.
|
||||||
|
|
||||||
### `GET /v0/groups/[slug]`
|
### `GET /v0/group/[slug]`
|
||||||
|
|
||||||
Gets a group by its slug.
|
Gets a group by its slug.
|
||||||
|
|
||||||
Requires no authorization.
|
Requires no authorization.
|
||||||
|
Note: group is singular in the URL.
|
||||||
|
|
||||||
### `GET /v0/group/by-id/[id]`
|
### `GET /v0/group/by-id/[id]`
|
||||||
|
|
||||||
Gets a group by its unique ID.
|
Gets a group by its unique ID.
|
||||||
|
|
||||||
Requires no authorization.
|
Requires no authorization.
|
||||||
|
Note: group is singular in the URL.
|
||||||
|
|
||||||
### `GET /v0/group/by-id/[id]/markets`
|
### `GET /v0/group/by-id/[id]/markets`
|
||||||
|
|
||||||
Gets a group's markets by its unique ID.
|
Gets a group's markets by its unique ID.
|
||||||
|
|
||||||
Requires no authorization.
|
Requires no authorization.
|
||||||
|
Note: group is singular in the URL.
|
||||||
|
|
||||||
|
|
||||||
### `GET /v0/markets`
|
### `GET /v0/markets`
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,30 @@
|
||||||
"functions": {
|
"functions": {
|
||||||
"predeploy": "cd functions && yarn build",
|
"predeploy": "cd functions && yarn build",
|
||||||
"runtime": "nodejs16",
|
"runtime": "nodejs16",
|
||||||
"source": "functions/dist"
|
"source": "functions/dist",
|
||||||
|
"ignore": [
|
||||||
|
"node_modules",
|
||||||
|
".git",
|
||||||
|
"firebase-debug.log",
|
||||||
|
"firebase-debug.*.log"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"firestore": {
|
"firestore": {
|
||||||
"rules": "firestore.rules",
|
"rules": "firestore.rules",
|
||||||
"indexes": "firestore.indexes.json"
|
"indexes": "firestore.indexes.json"
|
||||||
|
},
|
||||||
|
"emulators": {
|
||||||
|
"functions": {
|
||||||
|
"port": 5001
|
||||||
|
},
|
||||||
|
"firestore": {
|
||||||
|
"port": 8080
|
||||||
|
},
|
||||||
|
"pubsub": {
|
||||||
|
"port": 8085
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,7 +77,7 @@ service cloud.firestore {
|
||||||
allow read: if userId == request.auth.uid || isAdmin();
|
allow read: if userId == request.auth.uid || isAdmin();
|
||||||
allow update: if (userId == request.auth.uid || isAdmin())
|
allow update: if (userId == request.auth.uid || isAdmin())
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['apiKey', 'twitchInfo', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]);
|
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails','notificationSubscriptionTypes' ]);
|
||||||
}
|
}
|
||||||
|
|
||||||
match /private-users/{userId}/views/{viewId} {
|
match /private-users/{userId}/views/{viewId} {
|
||||||
|
@ -161,7 +161,7 @@ service cloud.firestore {
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['isSeen', 'viewTime']);
|
.hasOnly(['isSeen', 'viewTime']);
|
||||||
}
|
}
|
||||||
|
|
||||||
match /{somePath=**}/groupMembers/{memberId} {
|
match /{somePath=**}/groupMembers/{memberId} {
|
||||||
allow read;
|
allow read;
|
||||||
}
|
}
|
||||||
|
@ -170,7 +170,7 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
}
|
}
|
||||||
|
|
||||||
match /groups/{groupId} {
|
match /groups/{groupId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
|
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
|
||||||
&& request.resource.data.diff(resource.data)
|
&& request.resource.data.diff(resource.data)
|
||||||
|
@ -184,7 +184,7 @@ service cloud.firestore {
|
||||||
|
|
||||||
match /groupMembers/{memberId}{
|
match /groupMembers/{memberId}{
|
||||||
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
|
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
|
||||||
allow delete: if request.auth.uid == resource.data.userId;
|
allow delete: if request.auth.uid == resource.data.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGroupMember() {
|
function isGroupMember() {
|
||||||
|
|
1
functions/.gitignore
vendored
1
functions/.gitignore
vendored
|
@ -17,4 +17,5 @@ package-lock.json
|
||||||
ui-debug.log
|
ui-debug.log
|
||||||
firebase-debug.log
|
firebase-debug.log
|
||||||
firestore-debug.log
|
firestore-debug.log
|
||||||
|
pubsub-debug.log
|
||||||
firestore_export/
|
firestore_export/
|
||||||
|
|
|
@ -37,6 +37,45 @@ export const changeUser = async (
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
|
// Update contracts, comments, and answers outside of a transaction to avoid contention.
|
||||||
|
// Using bulkWriter to supports >500 writes at a time
|
||||||
|
const contractsRef = firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.where('creatorId', '==', user.id)
|
||||||
|
|
||||||
|
const contracts = await contractsRef.get()
|
||||||
|
|
||||||
|
const contractUpdate: Partial<Contract> = removeUndefinedProps({
|
||||||
|
creatorName: update.name,
|
||||||
|
creatorUsername: update.username,
|
||||||
|
creatorAvatarUrl: update.avatarUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
const commentSnap = await firestore
|
||||||
|
.collectionGroup('comments')
|
||||||
|
.where('userUsername', '==', user.username)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
const commentUpdate: Partial<Comment> = removeUndefinedProps({
|
||||||
|
userName: update.name,
|
||||||
|
userUsername: update.username,
|
||||||
|
userAvatarUrl: update.avatarUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
const answerSnap = await firestore
|
||||||
|
.collectionGroup('answers')
|
||||||
|
.where('username', '==', user.username)
|
||||||
|
.get()
|
||||||
|
const answerUpdate: Partial<Answer> = removeUndefinedProps(update)
|
||||||
|
|
||||||
|
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))
|
||||||
|
await bulkWriter.flush()
|
||||||
|
console.log('Done writing!')
|
||||||
|
|
||||||
|
// Update the username inside a transaction
|
||||||
return await firestore.runTransaction(async (transaction) => {
|
return await firestore.runTransaction(async (transaction) => {
|
||||||
if (update.username) {
|
if (update.username) {
|
||||||
update.username = cleanUsername(update.username)
|
update.username = cleanUsername(update.username)
|
||||||
|
@ -58,42 +97,7 @@ export const changeUser = async (
|
||||||
|
|
||||||
const userRef = firestore.collection('users').doc(user.id)
|
const userRef = firestore.collection('users').doc(user.id)
|
||||||
const userUpdate: Partial<User> = removeUndefinedProps(update)
|
const userUpdate: Partial<User> = removeUndefinedProps(update)
|
||||||
|
|
||||||
const contractsRef = firestore
|
|
||||||
.collection('contracts')
|
|
||||||
.where('creatorId', '==', user.id)
|
|
||||||
|
|
||||||
const contracts = await transaction.get(contractsRef)
|
|
||||||
|
|
||||||
const contractUpdate: Partial<Contract> = removeUndefinedProps({
|
|
||||||
creatorName: update.name,
|
|
||||||
creatorUsername: update.username,
|
|
||||||
creatorAvatarUrl: update.avatarUrl,
|
|
||||||
})
|
|
||||||
|
|
||||||
const commentSnap = await transaction.get(
|
|
||||||
firestore
|
|
||||||
.collectionGroup('comments')
|
|
||||||
.where('userUsername', '==', user.username)
|
|
||||||
)
|
|
||||||
|
|
||||||
const commentUpdate: Partial<Comment> = removeUndefinedProps({
|
|
||||||
userName: update.name,
|
|
||||||
userUsername: update.username,
|
|
||||||
userAvatarUrl: update.avatarUrl,
|
|
||||||
})
|
|
||||||
|
|
||||||
const answerSnap = await transaction.get(
|
|
||||||
firestore
|
|
||||||
.collectionGroup('answers')
|
|
||||||
.where('username', '==', user.username)
|
|
||||||
)
|
|
||||||
const answerUpdate: Partial<Answer> = removeUndefinedProps(update)
|
|
||||||
|
|
||||||
transaction.update(userRef, userUpdate)
|
transaction.update(userRef, userUpdate)
|
||||||
commentSnap.docs.forEach((d) => transaction.update(d.ref, commentUpdate))
|
|
||||||
answerSnap.docs.forEach((d) => transaction.update(d.ref, answerUpdate))
|
|
||||||
contracts.docs.forEach((d) => transaction.update(d.ref, contractUpdate))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,8 +5,7 @@ import { Contract } from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { getNewMultiBetInfo } from '../../common/new-bet'
|
import { getNewMultiBetInfo } from '../../common/new-bet'
|
||||||
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
|
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
|
||||||
import { getContract, getValues } from './utils'
|
import { getValues } from './utils'
|
||||||
import { sendNewAnswerEmail } from './emails'
|
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
|
@ -97,10 +96,6 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
|
||||||
return answer
|
return answer
|
||||||
})
|
})
|
||||||
|
|
||||||
const contract = await getContract(contractId)
|
|
||||||
|
|
||||||
if (answer && contract) await sendNewAnswerEmail(answer, contract)
|
|
||||||
|
|
||||||
return answer
|
return answer
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
MAX_GROUP_NAME_LENGTH,
|
MAX_GROUP_NAME_LENGTH,
|
||||||
MAX_ID_LENGTH,
|
MAX_ID_LENGTH,
|
||||||
} from '../../common/group'
|
} from '../../common/group'
|
||||||
import { APIError, newEndpoint, validate } from '../../functions/src/api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import {
|
import {
|
||||||
|
getDestinationsForUser,
|
||||||
Notification,
|
Notification,
|
||||||
notification_reason_types,
|
notification_reason_types,
|
||||||
notification_source_update_types,
|
|
||||||
notification_source_types,
|
|
||||||
} from '../../common/notification'
|
} from '../../common/notification'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { getValues, log } from './utils'
|
import { getPrivateUser, getValues } from './utils'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { Bet, LimitBet } from '../../common/bet'
|
import { Bet, LimitBet } from '../../common/bet'
|
||||||
|
@ -15,20 +14,27 @@ import { Answer } from '../../common/answer'
|
||||||
import { getContractBetMetrics } from '../../common/calculate'
|
import { getContractBetMetrics } from '../../common/calculate'
|
||||||
import { removeUndefinedProps } from '../../common/util/object'
|
import { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { TipTxn } from '../../common/txn'
|
import { TipTxn } from '../../common/txn'
|
||||||
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
import { Group } from '../../common/group'
|
||||||
import { Challenge } from '../../common/challenge'
|
import { Challenge } from '../../common/challenge'
|
||||||
import { richTextToString } from '../../common/util/parse'
|
|
||||||
import { Like } from '../../common/like'
|
import { Like } from '../../common/like'
|
||||||
|
import {
|
||||||
|
sendMarketCloseEmail,
|
||||||
|
sendMarketResolutionEmail,
|
||||||
|
sendNewAnswerEmail,
|
||||||
|
sendNewCommentEmail,
|
||||||
|
sendNewFollowedMarketEmail,
|
||||||
|
} from './emails'
|
||||||
|
import { filterDefined } from '../../common/util/array'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
type user_to_reason_texts = {
|
type recipients_to_reason_texts = {
|
||||||
[userId: string]: { reason: notification_reason_types }
|
[userId: string]: { reason: notification_reason_types }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createNotification = async (
|
export const createNotification = async (
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
sourceType: notification_source_types,
|
sourceType: 'contract' | 'liquidity' | 'follow',
|
||||||
sourceUpdateType: notification_source_update_types,
|
sourceUpdateType: 'closed' | 'created',
|
||||||
sourceUser: User,
|
sourceUser: User,
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
|
@ -41,9 +47,9 @@ export const createNotification = async (
|
||||||
) => {
|
) => {
|
||||||
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
|
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
|
||||||
|
|
||||||
const shouldGetNotification = (
|
const shouldReceiveNotification = (
|
||||||
userId: string,
|
userId: string,
|
||||||
userToReasonTexts: user_to_reason_texts
|
userToReasonTexts: recipients_to_reason_texts
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
sourceUser.id != userId &&
|
sourceUser.id != userId &&
|
||||||
|
@ -51,18 +57,25 @@ export const createNotification = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const createUsersNotifications = async (
|
const sendNotificationsIfSettingsPermit = async (
|
||||||
userToReasonTexts: user_to_reason_texts
|
userToReasonTexts: recipients_to_reason_texts
|
||||||
) => {
|
) => {
|
||||||
await Promise.all(
|
for (const userId in userToReasonTexts) {
|
||||||
Object.keys(userToReasonTexts).map(async (userId) => {
|
const { reason } = userToReasonTexts[userId]
|
||||||
|
const privateUser = await getPrivateUser(userId)
|
||||||
|
if (!privateUser) continue
|
||||||
|
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
reason
|
||||||
|
)
|
||||||
|
if (sendToBrowser) {
|
||||||
const notificationRef = firestore
|
const notificationRef = firestore
|
||||||
.collection(`/users/${userId}/notifications`)
|
.collection(`/users/${userId}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
const notification: Notification = {
|
const notification: Notification = {
|
||||||
id: idempotencyKey,
|
id: idempotencyKey,
|
||||||
userId,
|
userId,
|
||||||
reason: userToReasonTexts[userId].reason,
|
reason,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
isSeen: false,
|
isSeen: false,
|
||||||
sourceId,
|
sourceId,
|
||||||
|
@ -80,212 +93,232 @@ export const createNotification = async (
|
||||||
sourceTitle: title ? title : sourceContract?.question,
|
sourceTitle: title ? title : sourceContract?.question,
|
||||||
}
|
}
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
await notificationRef.set(removeUndefinedProps(notification))
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyUsersFollowers = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts
|
|
||||||
) => {
|
|
||||||
const followers = await firestore
|
|
||||||
.collectionGroup('follows')
|
|
||||||
.where('userId', '==', sourceUser.id)
|
|
||||||
.get()
|
|
||||||
|
|
||||||
followers.docs.forEach((doc) => {
|
|
||||||
const followerUserId = doc.ref.parent.parent?.id
|
|
||||||
if (
|
|
||||||
followerUserId &&
|
|
||||||
shouldGetNotification(followerUserId, userToReasonTexts)
|
|
||||||
) {
|
|
||||||
userToReasonTexts[followerUserId] = {
|
|
||||||
reason: 'you_follow_user',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyFollowedUser = (
|
if (!sendToEmail) continue
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
followedUserId: string
|
if (reason === 'your_contract_closed' && privateUser && sourceContract) {
|
||||||
) => {
|
// TODO: include number and names of bettors waiting for creator to resolve their market
|
||||||
if (shouldGetNotification(followedUserId, userToReasonTexts))
|
await sendMarketCloseEmail(
|
||||||
userToReasonTexts[followedUserId] = {
|
reason,
|
||||||
reason: 'on_new_follow',
|
sourceUser,
|
||||||
|
privateUser,
|
||||||
|
sourceContract
|
||||||
|
)
|
||||||
|
} else if (reason === 'subsidized_your_market') {
|
||||||
|
// TODO: send email to creator of market that was subsidized
|
||||||
|
} else if (reason === 'on_new_follow') {
|
||||||
|
// TODO: send email to user who was followed
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyTaggedUsers = (
|
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
userIds: (string | undefined)[]
|
|
||||||
) => {
|
|
||||||
userIds.forEach((id) => {
|
|
||||||
if (id && shouldGetNotification(id, userToReasonTexts))
|
|
||||||
userToReasonTexts[id] = {
|
|
||||||
reason: 'tagged_user',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyContractCreator = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
sourceContract: Contract,
|
|
||||||
options?: { force: boolean }
|
|
||||||
) => {
|
|
||||||
if (
|
|
||||||
options?.force ||
|
|
||||||
shouldGetNotification(sourceContract.creatorId, userToReasonTexts)
|
|
||||||
)
|
|
||||||
userToReasonTexts[sourceContract.creatorId] = {
|
|
||||||
reason: 'on_users_contract',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyUserAddedToGroup = (
|
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
relatedUserId: string
|
|
||||||
) => {
|
|
||||||
if (shouldGetNotification(relatedUserId, userToReasonTexts))
|
|
||||||
userToReasonTexts[relatedUserId] = {
|
|
||||||
reason: 'added_you_to_group',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const userToReasonTexts: user_to_reason_texts = {}
|
|
||||||
// The following functions modify the userToReasonTexts object in place.
|
// The following functions modify the userToReasonTexts object in place.
|
||||||
|
const userToReasonTexts: recipients_to_reason_texts = {}
|
||||||
|
|
||||||
if (sourceType === 'follow' && recipients?.[0]) {
|
if (sourceType === 'follow' && recipients?.[0]) {
|
||||||
notifyFollowedUser(userToReasonTexts, recipients[0])
|
if (shouldReceiveNotification(recipients[0], userToReasonTexts))
|
||||||
} else if (
|
userToReasonTexts[recipients[0]] = {
|
||||||
sourceType === 'group' &&
|
reason: 'on_new_follow',
|
||||||
sourceUpdateType === 'created' &&
|
}
|
||||||
recipients
|
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
||||||
) {
|
|
||||||
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
|
|
||||||
} else if (
|
|
||||||
sourceType === 'contract' &&
|
|
||||||
sourceUpdateType === 'created' &&
|
|
||||||
sourceContract
|
|
||||||
) {
|
|
||||||
await notifyUsersFollowers(userToReasonTexts)
|
|
||||||
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
|
||||||
} else if (
|
} else if (
|
||||||
sourceType === 'contract' &&
|
sourceType === 'contract' &&
|
||||||
sourceUpdateType === 'closed' &&
|
sourceUpdateType === 'closed' &&
|
||||||
sourceContract
|
sourceContract
|
||||||
) {
|
) {
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
userToReasonTexts[sourceContract.creatorId] = {
|
||||||
force: true,
|
reason: 'your_contract_closed',
|
||||||
})
|
}
|
||||||
|
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
||||||
} else if (
|
} else if (
|
||||||
sourceType === 'liquidity' &&
|
sourceType === 'liquidity' &&
|
||||||
sourceUpdateType === 'created' &&
|
sourceUpdateType === 'created' &&
|
||||||
sourceContract
|
sourceContract
|
||||||
) {
|
) {
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts))
|
||||||
|
userToReasonTexts[sourceContract.creatorId] = {
|
||||||
|
reason: 'subsidized_your_market',
|
||||||
|
}
|
||||||
|
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await createUsersNotifications(userToReasonTexts)
|
export type replied_users_info = {
|
||||||
|
[key: string]: {
|
||||||
|
repliedToType: 'comment' | 'answer'
|
||||||
|
repliedToAnswerText: string | undefined
|
||||||
|
repliedToId: string | undefined
|
||||||
|
bet: Bet | undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
sourceType: notification_source_types,
|
sourceType: 'comment' | 'answer' | 'contract',
|
||||||
sourceUpdateType: notification_source_update_types,
|
sourceUpdateType: 'created' | 'updated' | 'resolved',
|
||||||
sourceUser: User,
|
sourceUser: User,
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
sourceContract: Contract,
|
sourceContract: Contract,
|
||||||
miscData?: {
|
miscData?: {
|
||||||
relatedSourceType?: notification_source_types
|
repliedUsersInfo: replied_users_info
|
||||||
repliedUserId?: string
|
taggedUserIds: string[]
|
||||||
taggedUserIds?: string[]
|
},
|
||||||
|
resolutionData?: {
|
||||||
|
bets: Bet[]
|
||||||
|
userInvestments: { [userId: string]: number }
|
||||||
|
userPayouts: { [userId: string]: number }
|
||||||
|
creator: User
|
||||||
|
creatorPayout: number
|
||||||
|
contract: Contract
|
||||||
|
outcome: string
|
||||||
|
resolutionProbability?: number
|
||||||
|
resolutions?: { [outcome: string]: number }
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {}
|
const { repliedUsersInfo, taggedUserIds } = miscData ?? {}
|
||||||
|
|
||||||
const createUsersNotifications = async (
|
const browserRecipientIdsList: string[] = []
|
||||||
userToReasonTexts: user_to_reason_texts
|
const emailRecipientIdsList: string[] = []
|
||||||
) => {
|
|
||||||
await Promise.all(
|
|
||||||
Object.keys(userToReasonTexts).map(async (userId) => {
|
|
||||||
const notificationRef = firestore
|
|
||||||
.collection(`/users/${userId}/notifications`)
|
|
||||||
.doc(idempotencyKey)
|
|
||||||
const notification: Notification = {
|
|
||||||
id: idempotencyKey,
|
|
||||||
userId,
|
|
||||||
reason: userToReasonTexts[userId].reason,
|
|
||||||
createdTime: Date.now(),
|
|
||||||
isSeen: false,
|
|
||||||
sourceId,
|
|
||||||
sourceType,
|
|
||||||
sourceUpdateType,
|
|
||||||
sourceContractId: sourceContract.id,
|
|
||||||
sourceUserName: sourceUser.name,
|
|
||||||
sourceUserUsername: sourceUser.username,
|
|
||||||
sourceUserAvatarUrl: sourceUser.avatarUrl,
|
|
||||||
sourceText,
|
|
||||||
sourceContractCreatorUsername: sourceContract.creatorUsername,
|
|
||||||
sourceContractTitle: sourceContract.question,
|
|
||||||
sourceContractSlug: sourceContract.slug,
|
|
||||||
sourceSlug: sourceContract.slug,
|
|
||||||
sourceTitle: sourceContract.question,
|
|
||||||
}
|
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// get contract follower documents and check here if they're a follower
|
|
||||||
const contractFollowersSnap = await firestore
|
const contractFollowersSnap = await firestore
|
||||||
.collection(`contracts/${sourceContract.id}/follows`)
|
.collection(`contracts/${sourceContract.id}/follows`)
|
||||||
.get()
|
.get()
|
||||||
const contractFollowersIds = contractFollowersSnap.docs.map(
|
const contractFollowersIds = contractFollowersSnap.docs.map(
|
||||||
(doc) => doc.data().id
|
(doc) => doc.data().id
|
||||||
)
|
)
|
||||||
log('contractFollowerIds', contractFollowersIds)
|
|
||||||
|
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,
|
||||||
|
sourceType,
|
||||||
|
sourceUpdateType,
|
||||||
|
sourceContractId: sourceContract.id,
|
||||||
|
sourceUserName: sourceUser.name,
|
||||||
|
sourceUserUsername: sourceUser.username,
|
||||||
|
sourceUserAvatarUrl: sourceUser.avatarUrl,
|
||||||
|
sourceText,
|
||||||
|
sourceContractCreatorUsername: sourceContract.creatorUsername,
|
||||||
|
sourceContractTitle: sourceContract.question,
|
||||||
|
sourceContractSlug: sourceContract.slug,
|
||||||
|
sourceSlug: sourceContract.slug,
|
||||||
|
sourceTitle: sourceContract.question,
|
||||||
|
}
|
||||||
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
}
|
||||||
|
|
||||||
const stillFollowingContract = (userId: string) => {
|
const stillFollowingContract = (userId: string) => {
|
||||||
return contractFollowersIds.includes(userId)
|
return contractFollowersIds.includes(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldGetNotification = (
|
const sendNotificationsIfSettingsPermit = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
userToReasonTexts: user_to_reason_texts
|
reason: notification_reason_types
|
||||||
) => {
|
) => {
|
||||||
return (
|
if (
|
||||||
sourceUser.id != userId &&
|
!stillFollowingContract(sourceContract.creatorId) ||
|
||||||
!Object.keys(userToReasonTexts).includes(userId)
|
sourceUser.id == userId
|
||||||
|
)
|
||||||
|
return
|
||||||
|
const privateUser = await getPrivateUser(userId)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
reason
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
const notifyContractFollowers = async (
|
// Browser notifications
|
||||||
userToReasonTexts: user_to_reason_texts
|
if (sendToBrowser && !browserRecipientIdsList.includes(userId)) {
|
||||||
) => {
|
await createBrowserNotification(userId, reason)
|
||||||
for (const userId of contractFollowersIds) {
|
browserRecipientIdsList.push(userId)
|
||||||
if (shouldGetNotification(userId, userToReasonTexts))
|
}
|
||||||
userToReasonTexts[userId] = {
|
|
||||||
reason: 'you_follow_contract',
|
// Emails notifications
|
||||||
}
|
if (!sendToEmail || emailRecipientIdsList.includes(userId)) return
|
||||||
|
if (sourceType === 'comment') {
|
||||||
|
const { repliedToType, repliedToAnswerText, repliedToId, bet } =
|
||||||
|
repliedUsersInfo?.[userId] ?? {}
|
||||||
|
// TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment
|
||||||
|
await sendNewCommentEmail(
|
||||||
|
reason,
|
||||||
|
privateUser,
|
||||||
|
sourceUser,
|
||||||
|
sourceContract,
|
||||||
|
sourceText,
|
||||||
|
sourceId,
|
||||||
|
bet,
|
||||||
|
repliedToAnswerText,
|
||||||
|
repliedToType === 'answer' ? repliedToId : undefined
|
||||||
|
)
|
||||||
|
emailRecipientIdsList.push(userId)
|
||||||
|
} else if (sourceType === 'answer') {
|
||||||
|
await sendNewAnswerEmail(
|
||||||
|
reason,
|
||||||
|
privateUser,
|
||||||
|
sourceUser.name,
|
||||||
|
sourceText,
|
||||||
|
sourceContract,
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyContractCreator = async (
|
const notifyContractFollowers = async () => {
|
||||||
userToReasonTexts: user_to_reason_texts
|
for (const userId of contractFollowersIds) {
|
||||||
) => {
|
await sendNotificationsIfSettingsPermit(
|
||||||
if (
|
userId,
|
||||||
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) &&
|
sourceType === 'answer'
|
||||||
stillFollowingContract(sourceContract.creatorId)
|
? 'answer_on_contract_you_follow'
|
||||||
)
|
: sourceType === 'comment'
|
||||||
userToReasonTexts[sourceContract.creatorId] = {
|
? 'comment_on_contract_you_follow'
|
||||||
reason: 'on_users_contract',
|
: sourceUpdateType === 'updated'
|
||||||
}
|
? 'update_on_contract_you_follow'
|
||||||
|
: 'resolution_on_contract_you_follow'
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyOtherAnswerersOnContract = async (
|
const notifyContractCreator = async () => {
|
||||||
userToReasonTexts: user_to_reason_texts
|
await sendNotificationsIfSettingsPermit(
|
||||||
) => {
|
sourceContract.creatorId,
|
||||||
|
sourceType === 'comment'
|
||||||
|
? 'comment_on_your_contract'
|
||||||
|
: 'answer_on_your_contract'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyOtherAnswerersOnContract = async () => {
|
||||||
const answers = await getValues<Answer>(
|
const answers = await getValues<Answer>(
|
||||||
firestore
|
firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
|
@ -293,20 +326,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
.collection('answers')
|
.collection('answers')
|
||||||
)
|
)
|
||||||
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
|
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
|
||||||
recipientUserIds.forEach((userId) => {
|
await Promise.all(
|
||||||
if (
|
recipientUserIds.map((userId) =>
|
||||||
shouldGetNotification(userId, userToReasonTexts) &&
|
sendNotificationsIfSettingsPermit(
|
||||||
stillFollowingContract(userId)
|
userId,
|
||||||
|
sourceType === 'answer'
|
||||||
|
? 'answer_on_contract_with_users_answer'
|
||||||
|
: sourceType === 'comment'
|
||||||
|
? 'comment_on_contract_with_users_answer'
|
||||||
|
: sourceUpdateType === 'updated'
|
||||||
|
? 'update_on_contract_with_users_answer'
|
||||||
|
: 'resolution_on_contract_with_users_answer'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
userToReasonTexts[userId] = {
|
)
|
||||||
reason: 'on_contract_with_users_answer',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyOtherCommentersOnContract = async (
|
const notifyOtherCommentersOnContract = async () => {
|
||||||
userToReasonTexts: user_to_reason_texts
|
|
||||||
) => {
|
|
||||||
const comments = await getValues<Comment>(
|
const comments = await getValues<Comment>(
|
||||||
firestore
|
firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
|
@ -314,20 +350,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
.collection('comments')
|
.collection('comments')
|
||||||
)
|
)
|
||||||
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
|
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
|
||||||
recipientUserIds.forEach((userId) => {
|
await Promise.all(
|
||||||
if (
|
recipientUserIds.map((userId) =>
|
||||||
shouldGetNotification(userId, userToReasonTexts) &&
|
sendNotificationsIfSettingsPermit(
|
||||||
stillFollowingContract(userId)
|
userId,
|
||||||
|
sourceType === 'answer'
|
||||||
|
? 'answer_on_contract_with_users_comment'
|
||||||
|
: sourceType === 'comment'
|
||||||
|
? 'comment_on_contract_with_users_comment'
|
||||||
|
: sourceUpdateType === 'updated'
|
||||||
|
? 'update_on_contract_with_users_comment'
|
||||||
|
: 'resolution_on_contract_with_users_comment'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
userToReasonTexts[userId] = {
|
)
|
||||||
reason: 'on_contract_with_users_comment',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyBettorsOnContract = async (
|
const notifyBettorsOnContract = async () => {
|
||||||
userToReasonTexts: user_to_reason_texts
|
|
||||||
) => {
|
|
||||||
const betsSnap = await firestore
|
const betsSnap = await firestore
|
||||||
.collection(`contracts/${sourceContract.id}/bets`)
|
.collection(`contracts/${sourceContract.id}/bets`)
|
||||||
.get()
|
.get()
|
||||||
|
@ -343,88 +382,77 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
recipientUserIds.forEach((userId) => {
|
await Promise.all(
|
||||||
if (
|
recipientUserIds.map((userId) =>
|
||||||
shouldGetNotification(userId, userToReasonTexts) &&
|
sendNotificationsIfSettingsPermit(
|
||||||
stillFollowingContract(userId)
|
userId,
|
||||||
|
sourceType === 'answer'
|
||||||
|
? 'answer_on_contract_with_users_shares_in'
|
||||||
|
: sourceType === 'comment'
|
||||||
|
? 'comment_on_contract_with_users_shares_in'
|
||||||
|
: sourceUpdateType === 'updated'
|
||||||
|
? 'update_on_contract_with_users_shares_in'
|
||||||
|
: 'resolution_on_contract_with_users_shares_in'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
userToReasonTexts[userId] = {
|
)
|
||||||
reason: 'on_contract_with_users_shares_in',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyRepliedUser = (
|
const notifyRepliedUser = async () => {
|
||||||
userToReasonTexts: user_to_reason_texts,
|
if (sourceType === 'comment' && repliedUsersInfo)
|
||||||
relatedUserId: string,
|
await Promise.all(
|
||||||
relatedSourceType: notification_source_types
|
Object.keys(repliedUsersInfo).map((userId) =>
|
||||||
) => {
|
sendNotificationsIfSettingsPermit(
|
||||||
if (
|
userId,
|
||||||
shouldGetNotification(relatedUserId, userToReasonTexts) &&
|
repliedUsersInfo[userId].repliedToType === 'answer'
|
||||||
stillFollowingContract(relatedUserId)
|
? 'reply_to_users_answer'
|
||||||
) {
|
: 'reply_to_users_comment'
|
||||||
if (relatedSourceType === 'comment') {
|
)
|
||||||
userToReasonTexts[relatedUserId] = {
|
)
|
||||||
reason: 'reply_to_users_comment',
|
)
|
||||||
}
|
|
||||||
} else if (relatedSourceType === 'answer') {
|
|
||||||
userToReasonTexts[relatedUserId] = {
|
|
||||||
reason: 'reply_to_users_answer',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyTaggedUsers = (
|
const notifyTaggedUsers = async () => {
|
||||||
userToReasonTexts: user_to_reason_texts,
|
if (sourceType === 'comment' && taggedUserIds && taggedUserIds.length > 0)
|
||||||
userIds: (string | undefined)[]
|
await Promise.all(
|
||||||
) => {
|
taggedUserIds.map((userId) =>
|
||||||
userIds.forEach((id) => {
|
sendNotificationsIfSettingsPermit(userId, 'tagged_user')
|
||||||
console.log('tagged user: ', id)
|
)
|
||||||
// Allowing non-following users to get tagged
|
)
|
||||||
if (id && shouldGetNotification(id, userToReasonTexts))
|
|
||||||
userToReasonTexts[id] = {
|
|
||||||
reason: 'tagged_user',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyLiquidityProviders = async (
|
const notifyLiquidityProviders = async () => {
|
||||||
userToReasonTexts: user_to_reason_texts
|
|
||||||
) => {
|
|
||||||
const liquidityProviders = await firestore
|
const liquidityProviders = await firestore
|
||||||
.collection(`contracts/${sourceContract.id}/liquidity`)
|
.collection(`contracts/${sourceContract.id}/liquidity`)
|
||||||
.get()
|
.get()
|
||||||
const liquidityProvidersIds = uniq(
|
const liquidityProvidersIds = uniq(
|
||||||
liquidityProviders.docs.map((doc) => doc.data().userId)
|
liquidityProviders.docs.map((doc) => doc.data().userId)
|
||||||
)
|
)
|
||||||
liquidityProvidersIds.forEach((userId) => {
|
await Promise.all(
|
||||||
if (
|
liquidityProvidersIds.map((userId) =>
|
||||||
shouldGetNotification(userId, userToReasonTexts) &&
|
sendNotificationsIfSettingsPermit(
|
||||||
stillFollowingContract(userId)
|
userId,
|
||||||
) {
|
sourceType === 'answer'
|
||||||
userToReasonTexts[userId] = {
|
? 'answer_on_contract_with_users_shares_in'
|
||||||
reason: 'on_contract_with_users_shares_in',
|
: sourceType === 'comment'
|
||||||
}
|
? 'comment_on_contract_with_users_shares_in'
|
||||||
}
|
: sourceUpdateType === 'updated'
|
||||||
})
|
? 'update_on_contract_with_users_shares_in'
|
||||||
|
: 'resolution_on_contract_with_users_shares_in'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const userToReasonTexts: user_to_reason_texts = {}
|
|
||||||
|
|
||||||
if (sourceType === 'comment') {
|
await notifyRepliedUser()
|
||||||
if (repliedUserId && relatedSourceType)
|
await notifyTaggedUsers()
|
||||||
notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType)
|
await notifyContractCreator()
|
||||||
if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? [])
|
await notifyOtherAnswerersOnContract()
|
||||||
}
|
await notifyLiquidityProviders()
|
||||||
await notifyContractCreator(userToReasonTexts)
|
await notifyBettorsOnContract()
|
||||||
await notifyOtherAnswerersOnContract(userToReasonTexts)
|
await notifyOtherCommentersOnContract()
|
||||||
await notifyLiquidityProviders(userToReasonTexts)
|
// if they weren't notified previously, notify them now
|
||||||
await notifyBettorsOnContract(userToReasonTexts)
|
await notifyContractFollowers()
|
||||||
await notifyOtherCommentersOnContract(userToReasonTexts)
|
|
||||||
// if they weren't added previously, add them now
|
|
||||||
await notifyContractFollowers(userToReasonTexts)
|
|
||||||
|
|
||||||
await createUsersNotifications(userToReasonTexts)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createTipNotification = async (
|
export const createTipNotification = async (
|
||||||
|
@ -436,8 +464,15 @@ export const createTipNotification = async (
|
||||||
contract?: Contract,
|
contract?: Contract,
|
||||||
group?: Group
|
group?: Group
|
||||||
) => {
|
) => {
|
||||||
const slug = group ? group.slug + `#${commentId}` : commentId
|
const privateUser = await getPrivateUser(toUser.id)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser } = await getDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
'tip_received'
|
||||||
|
)
|
||||||
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
|
const slug = group ? group.slug + `#${commentId}` : commentId
|
||||||
const notificationRef = firestore
|
const notificationRef = firestore
|
||||||
.collection(`/users/${toUser.id}/notifications`)
|
.collection(`/users/${toUser.id}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
|
@ -461,6 +496,9 @@ export const createTipNotification = async (
|
||||||
sourceTitle: group?.name,
|
sourceTitle: group?.name,
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
|
||||||
|
// TODO: send notification to users that are watching the contract and want highly tipped comments only
|
||||||
|
// maybe TODO: send email notification to bet creator
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBetFillNotification = async (
|
export const createBetFillNotification = async (
|
||||||
|
@ -471,6 +509,14 @@ export const createBetFillNotification = async (
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
idempotencyKey: string
|
idempotencyKey: string
|
||||||
) => {
|
) => {
|
||||||
|
const privateUser = await getPrivateUser(toUser.id)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser } = await getDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
'bet_fill'
|
||||||
|
)
|
||||||
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id)
|
const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id)
|
||||||
const fillAmount = fill?.amount ?? 0
|
const fillAmount = fill?.amount ?? 0
|
||||||
|
|
||||||
|
@ -496,38 +542,8 @@ export const createBetFillNotification = async (
|
||||||
sourceContractId: contract.id,
|
sourceContractId: contract.id,
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
}
|
|
||||||
|
|
||||||
export const createGroupCommentNotification = async (
|
// maybe TODO: send email notification to bet creator
|
||||||
fromUser: User,
|
|
||||||
toUserId: string,
|
|
||||||
comment: Comment,
|
|
||||||
group: Group,
|
|
||||||
idempotencyKey: string
|
|
||||||
) => {
|
|
||||||
if (toUserId === fromUser.id) return
|
|
||||||
const notificationRef = firestore
|
|
||||||
.collection(`/users/${toUserId}/notifications`)
|
|
||||||
.doc(idempotencyKey)
|
|
||||||
const sourceSlug = `/group/${group.slug}/${GROUP_CHAT_SLUG}`
|
|
||||||
const notification: Notification = {
|
|
||||||
id: idempotencyKey,
|
|
||||||
userId: toUserId,
|
|
||||||
reason: 'on_group_you_are_member_of',
|
|
||||||
createdTime: Date.now(),
|
|
||||||
isSeen: false,
|
|
||||||
sourceId: comment.id,
|
|
||||||
sourceType: 'comment',
|
|
||||||
sourceUpdateType: 'created',
|
|
||||||
sourceUserName: fromUser.name,
|
|
||||||
sourceUserUsername: fromUser.username,
|
|
||||||
sourceUserAvatarUrl: fromUser.avatarUrl,
|
|
||||||
sourceText: richTextToString(comment.content),
|
|
||||||
sourceSlug,
|
|
||||||
sourceTitle: `${group.name}`,
|
|
||||||
isSeenOnHref: sourceSlug,
|
|
||||||
}
|
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createReferralNotification = async (
|
export const createReferralNotification = async (
|
||||||
|
@ -538,6 +554,14 @@ export const createReferralNotification = async (
|
||||||
referredByContract?: Contract,
|
referredByContract?: Contract,
|
||||||
referredByGroup?: Group
|
referredByGroup?: Group
|
||||||
) => {
|
) => {
|
||||||
|
const privateUser = await getPrivateUser(toUser.id)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser } = await getDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
'you_referred_user'
|
||||||
|
)
|
||||||
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
const notificationRef = firestore
|
const notificationRef = firestore
|
||||||
.collection(`/users/${toUser.id}/notifications`)
|
.collection(`/users/${toUser.id}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
|
@ -575,6 +599,8 @@ export const createReferralNotification = async (
|
||||||
: referredByContract?.question,
|
: referredByContract?.question,
|
||||||
}
|
}
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
|
||||||
|
// TODO send email notification
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createLoanIncomeNotification = async (
|
export const createLoanIncomeNotification = async (
|
||||||
|
@ -582,6 +608,14 @@ export const createLoanIncomeNotification = async (
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
income: number
|
income: number
|
||||||
) => {
|
) => {
|
||||||
|
const privateUser = await getPrivateUser(toUser.id)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser } = await getDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
'loan_income'
|
||||||
|
)
|
||||||
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
const notificationRef = firestore
|
const notificationRef = firestore
|
||||||
.collection(`/users/${toUser.id}/notifications`)
|
.collection(`/users/${toUser.id}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
|
@ -612,6 +646,14 @@ export const createChallengeAcceptedNotification = async (
|
||||||
acceptedAmount: number,
|
acceptedAmount: number,
|
||||||
contract: Contract
|
contract: Contract
|
||||||
) => {
|
) => {
|
||||||
|
const privateUser = await getPrivateUser(challengeCreator.id)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser } = await getDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
'challenge_accepted'
|
||||||
|
)
|
||||||
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
const notificationRef = firestore
|
const notificationRef = firestore
|
||||||
.collection(`/users/${challengeCreator.id}/notifications`)
|
.collection(`/users/${challengeCreator.id}/notifications`)
|
||||||
.doc()
|
.doc()
|
||||||
|
@ -645,6 +687,14 @@ export const createBettingStreakBonusNotification = async (
|
||||||
amount: number,
|
amount: number,
|
||||||
idempotencyKey: string
|
idempotencyKey: string
|
||||||
) => {
|
) => {
|
||||||
|
const privateUser = await getPrivateUser(user.id)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser } = await getDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
'betting_streak_incremented'
|
||||||
|
)
|
||||||
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
const notificationRef = firestore
|
const notificationRef = firestore
|
||||||
.collection(`/users/${user.id}/notifications`)
|
.collection(`/users/${user.id}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
|
@ -680,13 +730,24 @@ export const createLikeNotification = async (
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
tip?: TipTxn
|
tip?: TipTxn
|
||||||
) => {
|
) => {
|
||||||
|
const privateUser = await getPrivateUser(toUser.id)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser } = await getDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
'liked_and_tipped_your_contract'
|
||||||
|
)
|
||||||
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
|
// not handling just likes, must include tip
|
||||||
|
if (!tip) return
|
||||||
|
|
||||||
const notificationRef = firestore
|
const notificationRef = firestore
|
||||||
.collection(`/users/${toUser.id}/notifications`)
|
.collection(`/users/${toUser.id}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
const notification: Notification = {
|
const notification: Notification = {
|
||||||
id: idempotencyKey,
|
id: idempotencyKey,
|
||||||
userId: toUser.id,
|
userId: toUser.id,
|
||||||
reason: tip ? 'liked_and_tipped_your_contract' : 'liked_your_contract',
|
reason: 'liked_and_tipped_your_contract',
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
isSeen: false,
|
isSeen: false,
|
||||||
sourceId: like.id,
|
sourceId: like.id,
|
||||||
|
@ -703,20 +764,8 @@ export const createLikeNotification = async (
|
||||||
sourceTitle: contract.question,
|
sourceTitle: contract.question,
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
}
|
|
||||||
|
|
||||||
export async function filterUserIdsForOnlyFollowerIds(
|
// TODO send email notification
|
||||||
userIds: string[],
|
|
||||||
contractId: string
|
|
||||||
) {
|
|
||||||
// get contract follower documents and check here if they're a follower
|
|
||||||
const contractFollowersSnap = await firestore
|
|
||||||
.collection(`contracts/${contractId}/follows`)
|
|
||||||
.get()
|
|
||||||
const contractFollowersIds = contractFollowersSnap.docs.map(
|
|
||||||
(doc) => doc.data().id
|
|
||||||
)
|
|
||||||
return userIds.filter((id) => contractFollowersIds.includes(id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createUniqueBettorBonusNotification = async (
|
export const createUniqueBettorBonusNotification = async (
|
||||||
|
@ -727,6 +776,15 @@ export const createUniqueBettorBonusNotification = async (
|
||||||
amount: number,
|
amount: number,
|
||||||
idempotencyKey: string
|
idempotencyKey: string
|
||||||
) => {
|
) => {
|
||||||
|
console.log('createUniqueBettorBonusNotification')
|
||||||
|
const privateUser = await getPrivateUser(contractCreatorId)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser } = await getDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
'unique_bettors_on_your_contract'
|
||||||
|
)
|
||||||
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
const notificationRef = firestore
|
const notificationRef = firestore
|
||||||
.collection(`/users/${contractCreatorId}/notifications`)
|
.collection(`/users/${contractCreatorId}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
|
@ -752,4 +810,82 @@ export const createUniqueBettorBonusNotification = async (
|
||||||
sourceContractCreatorUsername: contract.creatorUsername,
|
sourceContractCreatorUsername: contract.creatorUsername,
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
|
||||||
|
// TODO send email notification
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createNewContractNotification = async (
|
||||||
|
contractCreator: User,
|
||||||
|
contract: Contract,
|
||||||
|
idempotencyKey: string,
|
||||||
|
text: string,
|
||||||
|
mentionedUserIds: string[]
|
||||||
|
) => {
|
||||||
|
if (contract.visibility !== 'public') return
|
||||||
|
|
||||||
|
const sendNotificationsIfSettingsAllow = async (
|
||||||
|
userId: string,
|
||||||
|
reason: notification_reason_types
|
||||||
|
) => {
|
||||||
|
const privateUser = await getPrivateUser(userId)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
reason
|
||||||
|
)
|
||||||
|
if (sendToBrowser) {
|
||||||
|
const notificationRef = firestore
|
||||||
|
.collection(`/users/${userId}/notifications`)
|
||||||
|
.doc(idempotencyKey)
|
||||||
|
const notification: Notification = {
|
||||||
|
id: idempotencyKey,
|
||||||
|
userId: userId,
|
||||||
|
reason,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
isSeen: false,
|
||||||
|
sourceId: contract.id,
|
||||||
|
sourceType: 'contract',
|
||||||
|
sourceUpdateType: 'created',
|
||||||
|
sourceUserName: contractCreator.name,
|
||||||
|
sourceUserUsername: contractCreator.username,
|
||||||
|
sourceUserAvatarUrl: contractCreator.avatarUrl,
|
||||||
|
sourceText: text,
|
||||||
|
sourceSlug: contract.slug,
|
||||||
|
sourceTitle: contract.question,
|
||||||
|
sourceContractSlug: contract.slug,
|
||||||
|
sourceContractId: contract.id,
|
||||||
|
sourceContractTitle: contract.question,
|
||||||
|
sourceContractCreatorUsername: contract.creatorUsername,
|
||||||
|
}
|
||||||
|
await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
}
|
||||||
|
if (!sendToEmail) return
|
||||||
|
if (reason === 'contract_from_followed_user')
|
||||||
|
await sendNewFollowedMarketEmail(reason, userId, privateUser, contract)
|
||||||
|
}
|
||||||
|
const followersSnapshot = await firestore
|
||||||
|
.collectionGroup('follows')
|
||||||
|
.where('userId', '==', contractCreator.id)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
const followerUserIds = filterDefined(
|
||||||
|
followersSnapshot.docs.map((doc) => {
|
||||||
|
const followerUserId = doc.ref.parent.parent?.id
|
||||||
|
return followerUserId && followerUserId != contractCreator.id
|
||||||
|
? followerUserId
|
||||||
|
: undefined
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// As it is coded now, the tag notification usurps the new contract notification
|
||||||
|
// It'd be easy to append the reason to the eventId if desired
|
||||||
|
for (const followerUserId of followerUserIds) {
|
||||||
|
await sendNotificationsIfSettingsAllow(
|
||||||
|
followerUserId,
|
||||||
|
'contract_from_followed_user'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const mentionedUserId of mentionedUserIds) {
|
||||||
|
await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { PrivateUser, User } from '../../common/user'
|
import {
|
||||||
|
getDefaultNotificationSettings,
|
||||||
|
PrivateUser,
|
||||||
|
User,
|
||||||
|
} from '../../common/user'
|
||||||
import { getUser, getUserByUsername, getValues } from './utils'
|
import { getUser, getUserByUsername, getValues } from './utils'
|
||||||
import { randomString } from '../../common/util/random'
|
import { randomString } from '../../common/util/random'
|
||||||
import {
|
import {
|
||||||
|
@ -79,6 +83,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
||||||
email,
|
email,
|
||||||
initialIpAddress: req.ip,
|
initialIpAddress: req.ip,
|
||||||
initialDeviceToken: deviceToken,
|
initialDeviceToken: deviceToken,
|
||||||
|
notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid),
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
||||||
|
|
|
@ -284,9 +284,12 @@
|
||||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
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
|
<p style="margin: 10px 0;">This e-mail has been sent to {{name}},
|
||||||
href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;"
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
target="_blank">click here to unsubscribe</a>.</p>
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -186,8 +186,9 @@
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
">Did you know you create your own prediction market on <a class="link-build-content"
|
">Did you know you can create your own prediction market on <a
|
||||||
style="color: #55575d" target="_blank" href="https://manifold.markets">Manifold</a> for
|
class="link-build-content" style="color: #55575d" target="_blank"
|
||||||
|
href="https://manifold.markets">Manifold</a> on
|
||||||
any question you care about?</span>
|
any question you care about?</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
@ -490,10 +491,10 @@
|
||||||
">
|
">
|
||||||
<p style="margin: 10px 0">
|
<p style="margin: 10px 0">
|
||||||
This e-mail has been sent to {{name}},
|
This e-mail has been sent to {{name}},
|
||||||
<a href="{{unsubscribeLink}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to unsubscribe</a>.
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -440,11 +440,10 @@
|
||||||
<p style="margin: 10px 0">
|
<p style="margin: 10px 0">
|
||||||
This e-mail has been sent to
|
This e-mail has been sent to
|
||||||
{{name}},
|
{{name}},
|
||||||
<a href="{{unsubscribeLink}}"
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
style="
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
" target="_blank">click here to unsubscribe</a> from future recommended markets.
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -526,19 +526,10 @@
|
||||||
"
|
"
|
||||||
>our Discord</a
|
>our Discord</a
|
||||||
>! Or,
|
>! Or,
|
||||||
<a
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
href="{{unsubscribeUrl}}"
|
color: inherit;
|
||||||
style="
|
text-decoration: none;
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
sans-serif;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
"
|
|
||||||
>unsubscribe</a
|
|
||||||
>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -367,14 +367,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">our Discord</a>! Or,
|
">our Discord</a>! Or,
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
color: inherit;
|
||||||
sans-serif;
|
text-decoration: none;
|
||||||
box-sizing: border-box;
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
">unsubscribe</a>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -485,14 +485,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">our Discord</a>! Or,
|
">our Discord</a>! Or,
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
color: inherit;
|
||||||
sans-serif;
|
text-decoration: none;
|
||||||
box-sizing: border-box;
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
">unsubscribe</a>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -367,14 +367,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">our Discord</a>! Or,
|
">our Discord</a>! Or,
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
color: inherit;
|
||||||
sans-serif;
|
text-decoration: none;
|
||||||
box-sizing: border-box;
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
">unsubscribe</a>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
491
functions/src/email-templates/market-resolved-no-bets.html
Normal file
491
functions/src/email-templates/market-resolved-no-bets.html
Normal file
|
@ -0,0 +1,491 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<title>Market resolved</title>
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 640px) {
|
||||||
|
body {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin: 20px 0 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrap {
|
||||||
|
padding: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body itemscope itemtype="http://schema.org/EmailMessage" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 1.6em;
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
margin: 0;
|
||||||
|
" bgcolor="#f6f6f6">
|
||||||
|
<table class="body-wrap" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
margin: 0;
|
||||||
|
" bgcolor="#f6f6f6">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
" valign="top"></td>
|
||||||
|
<td class="container" width="600" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
display: block !important;
|
||||||
|
max-width: 600px !important;
|
||||||
|
clear: both !important;
|
||||||
|
margin: 0 auto;
|
||||||
|
" valign="top">
|
||||||
|
<div class="content" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
max-width: 600px;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
">
|
||||||
|
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid #e9e9e9;
|
||||||
|
" bgcolor="#fff">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-wrap aligncenter" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
" align="center" valign="top">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
width: 90%;
|
||||||
|
">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-block" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 40px 0;
|
||||||
|
text-align: left;
|
||||||
|
" valign="top">
|
||||||
|
<a href="https://manifold.markets" target="_blank">
|
||||||
|
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
|
||||||
|
alt="Manifold Markets" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
">
|
||||||
|
<td class="content-block" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 6px 0;
|
||||||
|
text-align: left;
|
||||||
|
" valign="top">
|
||||||
|
{{creatorName}} asked
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-block" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 20px;
|
||||||
|
" valign="top">
|
||||||
|
<a href="{{url}}" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
'Lucida Grande', sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #000;
|
||||||
|
line-height: 1.2em;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
|
margin: 0 0 0 0;
|
||||||
|
color: #4337c9;
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
">
|
||||||
|
{{question}}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-block" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0px;
|
||||||
|
" valign="top">
|
||||||
|
<h2 class="aligncenter" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
'Lucida Grande', sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #000;
|
||||||
|
line-height: 1.2em;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
" align="center">
|
||||||
|
Resolved {{outcome}}
|
||||||
|
</h2>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="content-block aligncenter" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
" align="center" valign="top">
|
||||||
|
<table class="invoice" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: left;
|
||||||
|
width: 80%;
|
||||||
|
margin: 40px auto;
|
||||||
|
">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px 0;
|
||||||
|
" valign="top">
|
||||||
|
Dear {{name}},
|
||||||
|
<br style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
" />
|
||||||
|
<br style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
" />
|
||||||
|
A market you were following has been resolved!
|
||||||
|
<br style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
" />
|
||||||
|
<br style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
" />
|
||||||
|
Thanks,
|
||||||
|
<br style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
" />
|
||||||
|
Manifold Team
|
||||||
|
<br style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
" />
|
||||||
|
<br style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td style="padding: 10px 0 0 0; margin: 0">
|
||||||
|
<div align="center">
|
||||||
|
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
|
||||||
|
<a href="{{url}}" target="_blank" style="
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: inline-block;
|
||||||
|
font-family: arial, helvetica, sans-serif;
|
||||||
|
text-decoration: none;
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
text-align: center;
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: #11b981;
|
||||||
|
border-radius: 4px;
|
||||||
|
-webkit-border-radius: 4px;
|
||||||
|
-moz-border-radius: 4px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
mso-border-alt: none;
|
||||||
|
">
|
||||||
|
<span style="
|
||||||
|
display: block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
line-height: 120%;
|
||||||
|
"><span style="
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 18.8px;
|
||||||
|
">View market</span></span>
|
||||||
|
</a>
|
||||||
|
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div class="footer" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
clear: both;
|
||||||
|
color: #999;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
">
|
||||||
|
<table width="100%" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<tr style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
">
|
||||||
|
<td class="aligncenter content-block" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 12px;
|
||||||
|
vertical-align: top;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 20px;
|
||||||
|
" align="center" valign="top">
|
||||||
|
Questions? Come ask in
|
||||||
|
<a href="https://discord.gg/eHQBNBqXuh" style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
text-decoration: underline;
|
||||||
|
margin: 0;
|
||||||
|
">our Discord</a>! Or,
|
||||||
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0;
|
||||||
|
" valign="top"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -500,14 +500,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">our Discord</a>! Or,
|
">our Discord</a>! Or,
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
color: inherit;
|
||||||
sans-serif;
|
text-decoration: none;
|
||||||
box-sizing: border-box;
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 0;
|
|
||||||
">unsubscribe</a>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
354
functions/src/email-templates/new-market-from-followed-user.html
Normal file
354
functions/src/email-templates/new-market-from-followed-user.html
Normal file
|
@ -0,0 +1,354 @@
|
||||||
|
<!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>New market from {{creatorName}}</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]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||||
|
</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="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 5px 0px;padding-bottom:0px;padding-left:0px;padding-right: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="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;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" 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>
|
||||||
|
</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><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: 20px 0px 0px 0px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
padding-left: 0px;
|
||||||
|
padding-right: 0px;
|
||||||
|
padding-top: 20px;
|
||||||
|
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="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<p class="text-build-content"
|
||||||
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
|
data-testid="4XoHRGw1Y"><span
|
||||||
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
|
</span>Hi {{name}},</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<p class="text-build-content"
|
||||||
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
|
data-testid="4XoHRGw1Y"><span
|
||||||
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
|
{{creatorName}}, (who you're following) just created a new market, check it out!</span></p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||||
|
<a href="{{questionUrl}}">
|
||||||
|
<img alt="{{questionTitle}}" width="375" height="200"
|
||||||
|
style="border: 1px solid #4337c9;" src="{{questionImgSrc}}">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||||
|
<a href="{{questionUrl}}" target="_blank"
|
||||||
|
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||||
|
View market
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</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]></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;
|
||||||
|
word-break: break-word;
|
||||||
|
">
|
||||||
|
<div style="
|
||||||
|
font-family: Ubuntu, Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
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;
|
||||||
|
word-break: break-word;
|
||||||
|
"></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]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -1,519 +1,316 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
xmlns="http://www.w3.org/1999/xhtml"
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
xmlns:v="urn:schemas-microsoft-com:vml"
|
|
||||||
xmlns:o="urn:schemas-microsoft-com:office:office"
|
<head>
|
||||||
>
|
<title>Manifold Markets 7th Day Anniversary Gift!</title>
|
||||||
<head>
|
<!--[if !mso]><!-->
|
||||||
<title>7th Day Anniversary Gift!</title>
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<!--[if !mso]><!-->
|
<!--<![endif]-->
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
<!--<![endif]-->
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
<style type="text/css">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<style type="text/css">
|
|
||||||
#outlook a {
|
#outlook a {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
-ms-text-size-adjust: 100%;
|
-ms-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
table,
|
table,
|
||||||
td {
|
td {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
mso-table-lspace: 0pt;
|
mso-table-lspace: 0pt;
|
||||||
mso-table-rspace: 0pt;
|
mso-table-rspace: 0pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
border: 0;
|
border: 0;
|
||||||
height: auto;
|
height: auto;
|
||||||
line-height: 100%;
|
line-height: 100%;
|
||||||
outline: none;
|
outline: none;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
-ms-interpolation-mode: bicubic;
|
-ms-interpolation-mode: bicubic;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 13px 0;
|
margin: 13px 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!--[if mso]>
|
<!--[if mso]>
|
||||||
<noscript>
|
<noscript>
|
||||||
<xml>
|
<xml>
|
||||||
<o:OfficeDocumentSettings>
|
<o:OfficeDocumentSettings>
|
||||||
<o:AllowPNG />
|
<o:AllowPNG/>
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
</o:OfficeDocumentSettings>
|
</o:OfficeDocumentSettings>
|
||||||
</xml> </noscript
|
</xml>
|
||||||
>z
|
</noscript>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<!--[if lte mso 11]>
|
<!--[if lte mso 11]>
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
.mj-outlook-group-fix {
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
width: 100% !important;
|
</style>
|
||||||
}
|
<![endif]-->
|
||||||
</style>
|
<style type="text/css">
|
||||||
<![endif]-->
|
@media only screen and (min-width:480px) {
|
||||||
<style type="text/css">
|
.mj-column-per-100 {
|
||||||
@media only screen and (min-width: 480px) {
|
width: 100% !important;
|
||||||
.mj-column-per-100 {
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style media="screen and (min-width:480px)">
|
||||||
|
.moz-text-html .mj-column-per-100 {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<style media="screen and (min-width:480px)">
|
<style type="text/css">
|
||||||
.moz-text-html .mj-column-per-100 {
|
|
||||||
width: 100% !important;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<style type="text/css">
|
|
||||||
[owa] .mj-column-per-100 {
|
[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;
|
width: 100% !important;
|
||||||
}
|
max-width: 100%;
|
||||||
|
|
||||||
td.mj-full-width-mobile {
|
|
||||||
width: auto !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width:480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
td.mj-full-width-mobile {
|
||||||
<div style="background-color: #f4f4f4">
|
width: auto !important;
|
||||||
<!--[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="
|
</style>
|
||||||
background: #ffffff;
|
</head>
|
||||||
background-color: #ffffff;
|
|
||||||
margin: 0px auto;
|
<body style="word-spacing:normal;background-color:#F4F4F4;">
|
||||||
max-width: 600px;
|
<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
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
align="center"
|
style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||||
border="0"
|
<tbody>
|
||||||
cellpadding="0"
|
<tr>
|
||||||
cellspacing="0"
|
<td
|
||||||
role="presentation"
|
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;">
|
||||||
style="background: #ffffff; background-color: #ffffff; width: 100%"
|
<!--[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"
|
||||||
<tbody>
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
<tr>
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
<td
|
width="100%">
|
||||||
style="
|
<tbody>
|
||||||
direction: ltr;
|
<tr>
|
||||||
font-size: 0px;
|
<td align="center"
|
||||||
padding: 0px 0px 0px 0px;
|
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;">
|
||||||
padding-bottom: 0px;
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
padding-left: 0px;
|
style="border-collapse:collapse;border-spacing: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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td style="width:550px;"><a href="https://manifold.markets/home" target="_blank"><img
|
||||||
align="center"
|
alt="" height="auto" src="https://i.imgur.com/8EP8Y8q.gif"
|
||||||
style="
|
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||||
font-size: 0px;
|
width="550"></a></td>
|
||||||
padding: 0px 25px 0px 25px;
|
</tr>
|
||||||
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://03jlj.mjt.lu/img/03jlj/b/u71/sjvu.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="
|
|
||||||
text-align: center;
|
|
||||||
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;
|
|
||||||
"
|
|
||||||
>Hopefully you haven't gambled all your M$
|
|
||||||
away already... but if you have I bring good
|
|
||||||
news! Click the link below to recieve a one time
|
|
||||||
gift of M$ 500 to your account!</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
align="center"
|
|
||||||
style="
|
|
||||||
font-size: 0px;
|
|
||||||
padding: 10px 25px 25px 25px;
|
|
||||||
padding-top: 10px;
|
|
||||||
padding-right: 25px;
|
|
||||||
padding-bottom: 25px;
|
|
||||||
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="{{manalink}}" target="_blank">
|
|
||||||
<img
|
|
||||||
alt="Get M$500"
|
|
||||||
height="auto"
|
|
||||||
src="https://03jlj.mjt.lu/img/03jlj/b/u71/sjgt.png"
|
|
||||||
style="
|
|
||||||
border: none;
|
|
||||||
display: block;
|
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
height: auto;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 13px;
|
|
||||||
"
|
|
||||||
width="550"
|
|
||||||
/></a>
|
|
||||||
<< /td>
|
|
||||||
</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: 23px;
|
|
||||||
text-align: center;
|
|
||||||
margin: 10px 0;
|
|
||||||
margin-top: 10px;
|
|
||||||
"
|
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
color: #000000;
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
>If you are still engaging with our markets then
|
|
||||||
at this point you might as well join our </span
|
|
||||||
><a
|
|
||||||
class="link-build-content"
|
|
||||||
style="color: inherit; text-decoration: none"
|
|
||||||
target="_blank"
|
|
||||||
href="https://discord.gg/VARzUpyCSa"
|
|
||||||
><span
|
|
||||||
style="
|
|
||||||
color: #0c21bf;
|
|
||||||
font-family: Arial;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
><u>Discord server</u></span
|
|
||||||
><span
|
|
||||||
style="
|
|
||||||
color: #000000;
|
|
||||||
font-family: Arial;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
><u>.</u>
|
|
||||||
</span></a
|
|
||||||
><span
|
|
||||||
style="
|
|
||||||
color: #000000;
|
|
||||||
font-family: Arial;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
>You can always leave if you dont like it but
|
|
||||||
I'd be willing to make a market betting
|
|
||||||
you'll stay.</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-build-content"
|
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
style="margin: 10px 0"
|
|
||||||
></p>
|
|
||||||
<br />
|
|
||||||
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</td>
|
||||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
</tr>
|
||||||
</td>
|
<tr>
|
||||||
</tr>
|
<td align="left"
|
||||||
</tbody>
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
</table>
|
<div
|
||||||
</div>
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
<!--[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]-->
|
<p class="text-build-content"
|
||||||
<div style="margin: 0px auto; max-width: 600px">
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
<table
|
data-testid="4XoHRGw1Y"><span
|
||||||
align="center"
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
border="0"
|
Hi {{name}},</span></p>
|
||||||
cellpadding="0"
|
</div>
|
||||||
cellspacing="0"
|
</td>
|
||||||
role="presentation"
|
</tr>
|
||||||
style="width: 100%"
|
<tr>
|
||||||
>
|
<td align="left"
|
||||||
<tbody>
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
<tr>
|
<div
|
||||||
<td
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
style="
|
<p class="text-build-content"
|
||||||
direction: ltr;
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
font-size: 0px;
|
data-testid="4XoHRGw1Y"><span
|
||||||
padding: 20px 0px 20px 0px;
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Thanks for
|
||||||
text-align: center;
|
using Manifold Markets. Running low
|
||||||
"
|
on mana (M$)? Click the link below to receive a one time gift of M$500!</span></p>
|
||||||
>
|
</div>
|
||||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
</td>
|
||||||
<div
|
</tr>
|
||||||
class="mj-column-per-100 mj-outlook-group-fix"
|
<tr>
|
||||||
style="
|
<td>
|
||||||
font-size: 0px;
|
<p></p>
|
||||||
text-align: left;
|
</td>
|
||||||
direction: ltr;
|
</tr>
|
||||||
display: inline-block;
|
<tr>
|
||||||
vertical-align: top;
|
<td align="center">
|
||||||
width: 100%;
|
<table cellspacing="0" cellpadding="0">
|
||||||
"
|
<tr>
|
||||||
>
|
<td>
|
||||||
<table
|
<table cellspacing="0" cellpadding="0">
|
||||||
border="0"
|
<tr>
|
||||||
cellpadding="0"
|
<td style="border-radius: 2px;" bgcolor="#4337c9">
|
||||||
cellspacing="0"
|
<a href="{{manalink}}" target="_blank"
|
||||||
role="presentation"
|
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;">
|
||||||
width="100%"
|
Claim M$500
|
||||||
>
|
</a>
|
||||||
<tbody>
|
</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;">Predicting
|
||||||
|
consecutive days to earn streak rewards</span></li>
|
||||||
|
<li style="line-height:23px;"><span
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;">Receiving
|
||||||
|
tips on comments and markets</span></li>
|
||||||
|
<li style="line-height:23px;"><span
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;">Unique
|
||||||
|
predictor bonus for each user who predicts 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>
|
||||||
|
</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>
|
<tr>
|
||||||
<td style="vertical-align: top; padding: 0">
|
<td style="vertical-align:top;padding:0;">
|
||||||
<table
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="center" style="
|
||||||
align="center"
|
|
||||||
style="
|
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
padding: 10px 25px;
|
padding: 10px 25px;
|
||||||
padding-top: 0px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
"
|
">
|
||||||
>
|
<div style="
|
||||||
<div
|
font-family: Ubuntu, Helvetica, Arial,
|
||||||
style="
|
sans-serif;
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
letter-spacing: normal;
|
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
"
|
">
|
||||||
>
|
<p style="margin: 10px 0">
|
||||||
<p style="margin: 10px 0">
|
This e-mail has been sent to {{name}},
|
||||||
This e-mail has been sent to {{name}},
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
<a
|
|
||||||
href="{{unsubscribeLink}}"
|
|
||||||
style="
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
"
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
target="_blank"
|
</p>
|
||||||
>click here to unsubscribe</a
|
</div>
|
||||||
>.
|
</td>
|
||||||
</p>
|
</tr>
|
||||||
</div>
|
<tr>
|
||||||
</td>
|
<td align="center"
|
||||||
</tr>
|
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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
|
@ -214,10 +214,12 @@
|
||||||
<div
|
<div
|
||||||
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
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
|
<p style="margin: 10px 0;">This e-mail has been sent
|
||||||
to {{name}}, <a href="{{unsubscribeLink}}"
|
to {{name}},
|
||||||
style="color:inherit;text-decoration:none;"
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
target="_blank">click here to
|
color: inherit;
|
||||||
unsubscribe</a>.</p>
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -137,7 +137,7 @@
|
||||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
data-testid="4XoHRGw1Y"><span
|
data-testid="4XoHRGw1Y"><span
|
||||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
Welcome! Manifold Markets is a play-money prediction market platform where you can bet on
|
Welcome! Manifold Markets is a play-money prediction market platform where you can predict
|
||||||
anything, from elections to Elon Musk to scientific papers to the NBA. </span></p>
|
anything, from elections to Elon Musk to scientific papers to the NBA. </span></p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -286,9 +286,12 @@
|
||||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
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
|
<p style="margin: 10px 0;">This e-mail has been sent to {{name}},
|
||||||
href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;"
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
target="_blank">click here to unsubscribe</a>.</p>
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to manage your notifications</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import { DOMAIN } from '../../common/envs/constants'
|
import { DOMAIN } from '../../common/envs/constants'
|
||||||
import { Answer } from '../../common/answer'
|
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { getProbability } from '../../common/calculate'
|
import { getProbability } from '../../common/calculate'
|
||||||
import { Comment } from '../../common/comment'
|
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { PrivateUser, User } from '../../common/user'
|
import {
|
||||||
|
notification_subscription_types,
|
||||||
|
PrivateUser,
|
||||||
|
User,
|
||||||
|
} from '../../common/user'
|
||||||
import {
|
import {
|
||||||
formatLargeNumber,
|
formatLargeNumber,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
|
@ -14,15 +16,16 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
|
||||||
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||||
|
|
||||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||||
import { getPrivateUser, getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { getFunctionUrl } from '../../common/api'
|
|
||||||
import { richTextToString } from '../../common/util/parse'
|
|
||||||
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
||||||
|
import {
|
||||||
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
|
notification_reason_types,
|
||||||
|
getDestinationsForUser,
|
||||||
|
} from '../../common/notification'
|
||||||
|
|
||||||
export const sendMarketResolutionEmail = async (
|
export const sendMarketResolutionEmail = async (
|
||||||
userId: string,
|
reason: notification_reason_types,
|
||||||
|
privateUser: PrivateUser,
|
||||||
investment: number,
|
investment: number,
|
||||||
payout: number,
|
payout: number,
|
||||||
creator: User,
|
creator: User,
|
||||||
|
@ -32,15 +35,11 @@ export const sendMarketResolutionEmail = async (
|
||||||
resolutionProbability?: number,
|
resolutionProbability?: number,
|
||||||
resolutions?: { [outcome: string]: number }
|
resolutions?: { [outcome: string]: number }
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(userId)
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
if (
|
await getDestinationsForUser(privateUser, reason)
|
||||||
!privateUser ||
|
if (!privateUser || !privateUser.email || !sendToEmail) return
|
||||||
privateUser.unsubscribedFromResolutionEmails ||
|
|
||||||
!privateUser.email
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const user = await getUser(userId)
|
const user = await getUser(privateUser.id)
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
const outcome = toDisplayResolution(
|
const outcome = toDisplayResolution(
|
||||||
|
@ -53,17 +52,13 @@ export const sendMarketResolutionEmail = async (
|
||||||
const subject = `Resolved ${outcome}: ${contract.question}`
|
const subject = `Resolved ${outcome}: ${contract.question}`
|
||||||
|
|
||||||
const creatorPayoutText =
|
const creatorPayoutText =
|
||||||
creatorPayout >= 1 && userId === creator.id
|
creatorPayout >= 1 && privateUser.id === creator.id
|
||||||
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
const emailType = 'market-resolved'
|
const correctedInvestment =
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
Number.isNaN(investment) || investment < 0 ? 0 : investment
|
||||||
|
const displayedInvestment = formatMoney(correctedInvestment)
|
||||||
const displayedInvestment =
|
|
||||||
Number.isNaN(investment) || investment < 0
|
|
||||||
? formatMoney(0)
|
|
||||||
: formatMoney(investment)
|
|
||||||
|
|
||||||
const displayedPayout = formatMoney(payout)
|
const displayedPayout = formatMoney(payout)
|
||||||
|
|
||||||
|
@ -85,7 +80,7 @@ export const sendMarketResolutionEmail = async (
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
subject,
|
subject,
|
||||||
'market-resolved',
|
correctedInvestment === 0 ? 'market-resolved-no-bets' : 'market-resolved',
|
||||||
templateData
|
templateData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -154,11 +149,12 @@ export const sendWelcomeEmail = async (
|
||||||
) => {
|
) => {
|
||||||
if (!privateUser || !privateUser.email) return
|
if (!privateUser || !privateUser.email) return
|
||||||
|
|
||||||
const { name, id: userId } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const emailType = 'generic'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
'onboarding_flow' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -166,7 +162,7 @@ export const sendWelcomeEmail = async (
|
||||||
'welcome',
|
'welcome',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink,
|
unsubscribeUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: 'David from Manifold <david@manifold.markets>',
|
from: 'David from Manifold <david@manifold.markets>',
|
||||||
|
@ -217,23 +213,23 @@ export const sendOneWeekBonusEmail = async (
|
||||||
if (
|
if (
|
||||||
!privateUser ||
|
!privateUser ||
|
||||||
!privateUser.email ||
|
!privateUser.email ||
|
||||||
privateUser.unsubscribedFromGenericEmails
|
!privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email')
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const { name, id: userId } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const emailType = 'generic'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
'onboarding_flow' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Manifold Markets one week anniversary gift',
|
'Manifold Markets one week anniversary gift',
|
||||||
'one-week',
|
'one-week',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink,
|
unsubscribeUrl,
|
||||||
manalink: 'https://manifold.markets/link/lj4JbBvE',
|
manalink: 'https://manifold.markets/link/lj4JbBvE',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -250,23 +246,23 @@ export const sendCreatorGuideEmail = async (
|
||||||
if (
|
if (
|
||||||
!privateUser ||
|
!privateUser ||
|
||||||
!privateUser.email ||
|
!privateUser.email ||
|
||||||
privateUser.unsubscribedFromGenericEmails
|
!privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email')
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const { name, id: userId } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const emailType = 'generic'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
'onboarding_flow' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Create your own prediction market',
|
'Create your own prediction market',
|
||||||
'creating-market',
|
'creating-market',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink,
|
unsubscribeUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: 'David from Manifold <david@manifold.markets>',
|
from: 'David from Manifold <david@manifold.markets>',
|
||||||
|
@ -282,15 +278,18 @@ export const sendThankYouEmail = async (
|
||||||
if (
|
if (
|
||||||
!privateUser ||
|
!privateUser ||
|
||||||
!privateUser.email ||
|
!privateUser.email ||
|
||||||
privateUser.unsubscribedFromGenericEmails
|
!privateUser.notificationSubscriptionTypes.thank_you_for_purchases.includes(
|
||||||
|
'email'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const { name, id: userId } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const emailType = 'generic'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
'thank_you_for_purchases' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -298,7 +297,7 @@ export const sendThankYouEmail = async (
|
||||||
'thank-you',
|
'thank-you',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink,
|
unsubscribeUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: 'David from Manifold <david@manifold.markets>',
|
from: 'David from Manifold <david@manifold.markets>',
|
||||||
|
@ -307,16 +306,15 @@ export const sendThankYouEmail = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendMarketCloseEmail = async (
|
export const sendMarketCloseEmail = async (
|
||||||
|
reason: notification_reason_types,
|
||||||
user: User,
|
user: User,
|
||||||
privateUser: PrivateUser,
|
privateUser: PrivateUser,
|
||||||
contract: Contract
|
contract: Contract
|
||||||
) => {
|
) => {
|
||||||
if (
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
!privateUser ||
|
await getDestinationsForUser(privateUser, reason)
|
||||||
privateUser.unsubscribedFromResolutionEmails ||
|
|
||||||
!privateUser.email
|
if (!privateUser.email || !sendToEmail) return
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { username, name, id: userId } = user
|
const { username, name, id: userId } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
@ -324,8 +322,6 @@ export const sendMarketCloseEmail = async (
|
||||||
const { question, slug, volume } = contract
|
const { question, slug, volume } = contract
|
||||||
|
|
||||||
const url = `https://${DOMAIN}/${username}/${slug}`
|
const url = `https://${DOMAIN}/${username}/${slug}`
|
||||||
const emailType = 'market-resolve'
|
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -343,30 +339,24 @@ export const sendMarketCloseEmail = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendNewCommentEmail = async (
|
export const sendNewCommentEmail = async (
|
||||||
userId: string,
|
reason: notification_reason_types,
|
||||||
|
privateUser: PrivateUser,
|
||||||
commentCreator: User,
|
commentCreator: User,
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
comment: Comment,
|
commentText: string,
|
||||||
|
commentId: string,
|
||||||
bet?: Bet,
|
bet?: Bet,
|
||||||
answerText?: string,
|
answerText?: string,
|
||||||
answerId?: string
|
answerId?: string
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(userId)
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
if (
|
await getDestinationsForUser(privateUser, reason)
|
||||||
!privateUser ||
|
if (!privateUser || !privateUser.email || !sendToEmail) return
|
||||||
!privateUser.email ||
|
|
||||||
privateUser.unsubscribedFromCommentEmails
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { question, creatorUsername, slug } = contract
|
const { question } = contract
|
||||||
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}`
|
const marketUrl = `https://${DOMAIN}/${contract.creatorUsername}/${contract.slug}#${commentId}`
|
||||||
const emailType = 'market-comment'
|
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
|
||||||
|
|
||||||
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
|
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
|
||||||
const { content } = comment
|
|
||||||
const text = richTextToString(content)
|
|
||||||
|
|
||||||
let betDescription = ''
|
let betDescription = ''
|
||||||
if (bet) {
|
if (bet) {
|
||||||
|
@ -380,7 +370,7 @@ export const sendNewCommentEmail = async (
|
||||||
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
|
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
|
||||||
|
|
||||||
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
||||||
const answerNumber = `#${answerId}`
|
const answerNumber = answerId ? `#${answerId}` : ''
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -391,7 +381,7 @@ export const sendNewCommentEmail = async (
|
||||||
answerNumber,
|
answerNumber,
|
||||||
commentorName,
|
commentorName,
|
||||||
commentorAvatarUrl: commentorAvatarUrl ?? '',
|
commentorAvatarUrl: commentorAvatarUrl ?? '',
|
||||||
comment: text,
|
comment: commentText,
|
||||||
marketUrl,
|
marketUrl,
|
||||||
unsubscribeUrl,
|
unsubscribeUrl,
|
||||||
betDescription,
|
betDescription,
|
||||||
|
@ -412,7 +402,7 @@ export const sendNewCommentEmail = async (
|
||||||
{
|
{
|
||||||
commentorName,
|
commentorName,
|
||||||
commentorAvatarUrl: commentorAvatarUrl ?? '',
|
commentorAvatarUrl: commentorAvatarUrl ?? '',
|
||||||
comment: text,
|
comment: commentText,
|
||||||
marketUrl,
|
marketUrl,
|
||||||
unsubscribeUrl,
|
unsubscribeUrl,
|
||||||
betDescription,
|
betDescription,
|
||||||
|
@ -423,29 +413,24 @@ export const sendNewCommentEmail = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendNewAnswerEmail = async (
|
export const sendNewAnswerEmail = async (
|
||||||
answer: Answer,
|
reason: notification_reason_types,
|
||||||
contract: Contract
|
privateUser: PrivateUser,
|
||||||
|
name: string,
|
||||||
|
text: string,
|
||||||
|
contract: Contract,
|
||||||
|
avatarUrl?: string
|
||||||
) => {
|
) => {
|
||||||
// Send to just the creator for now.
|
const { creatorId } = contract
|
||||||
const { creatorId: userId } = contract
|
|
||||||
|
|
||||||
// Don't send the creator's own answers.
|
// Don't send the creator's own answers.
|
||||||
if (answer.userId === userId) return
|
if (privateUser.id === creatorId) return
|
||||||
|
|
||||||
const privateUser = await getPrivateUser(userId)
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
if (
|
await getDestinationsForUser(privateUser, reason)
|
||||||
!privateUser ||
|
if (!privateUser.email || !sendToEmail) return
|
||||||
!privateUser.email ||
|
|
||||||
privateUser.unsubscribedFromAnswerEmails
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { question, creatorUsername, slug } = contract
|
const { question, creatorUsername, slug } = contract
|
||||||
const { name, avatarUrl, text } = answer
|
|
||||||
|
|
||||||
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}`
|
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}`
|
||||||
const emailType = 'market-answer'
|
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
|
||||||
|
|
||||||
const subject = `New answer on ${question}`
|
const subject = `New answer on ${question}`
|
||||||
const from = `${name} <info@manifold.markets>`
|
const from = `${name} <info@manifold.markets>`
|
||||||
|
@ -474,12 +459,15 @@ export const sendInterestingMarketsEmail = async (
|
||||||
if (
|
if (
|
||||||
!privateUser ||
|
!privateUser ||
|
||||||
!privateUser.email ||
|
!privateUser.email ||
|
||||||
privateUser?.unsubscribedFromWeeklyTrendingEmails
|
!privateUser.notificationSubscriptionTypes.trending_markets.includes(
|
||||||
|
'email'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const emailType = 'weekly-trending'
|
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${privateUser.id}&type=${emailType}`
|
'trending_markets' as keyof notification_subscription_types
|
||||||
|
}`
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
@ -490,7 +478,7 @@ export const sendInterestingMarketsEmail = async (
|
||||||
'interesting-markets',
|
'interesting-markets',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink: unsubscribeUrl,
|
unsubscribeUrl,
|
||||||
|
|
||||||
question1Title: contractsToSend[0].question,
|
question1Title: contractsToSend[0].question,
|
||||||
question1Link: contractUrl(contractsToSend[0]),
|
question1Link: contractUrl(contractsToSend[0]),
|
||||||
|
@ -522,3 +510,37 @@ function contractUrl(contract: Contract) {
|
||||||
function imageSourceUrl(contract: Contract) {
|
function imageSourceUrl(contract: Contract) {
|
||||||
return buildCardUrl(getOpenGraphProps(contract))
|
return buildCardUrl(getOpenGraphProps(contract))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const sendNewFollowedMarketEmail = async (
|
||||||
|
reason: notification_reason_types,
|
||||||
|
userId: string,
|
||||||
|
privateUser: PrivateUser,
|
||||||
|
contract: Contract
|
||||||
|
) => {
|
||||||
|
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
|
||||||
|
await getDestinationsForUser(privateUser, reason)
|
||||||
|
if (!privateUser.email || !sendToEmail) return
|
||||||
|
const user = await getUser(privateUser.id)
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
|
const { name } = user
|
||||||
|
const firstName = name.split(' ')[0]
|
||||||
|
const creatorName = contract.creatorName
|
||||||
|
|
||||||
|
return await sendTemplateEmail(
|
||||||
|
privateUser.email,
|
||||||
|
`${creatorName} asked ${contract.question}`,
|
||||||
|
'new-market-from-followed-user',
|
||||||
|
{
|
||||||
|
name: firstName,
|
||||||
|
creatorName,
|
||||||
|
unsubscribeUrl,
|
||||||
|
questionTitle: contract.question,
|
||||||
|
questionUrl: contractUrl(contract),
|
||||||
|
questionImgSrc: imageSourceUrl(contract),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: `${creatorName} on Manifold <no-reply@manifold.markets>`,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@ import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { getPrivateUser, getUserByUsername } from './utils'
|
import { getPrivateUser, getUserByUsername } from './utils'
|
||||||
import { sendMarketCloseEmail } from './emails'
|
|
||||||
import { createNotification } from './create-notification'
|
import { createNotification } from './create-notification'
|
||||||
|
|
||||||
export const marketCloseNotifications = functions
|
export const marketCloseNotifications = functions
|
||||||
|
@ -56,7 +55,6 @@ async function sendMarketCloseEmails() {
|
||||||
const privateUser = await getPrivateUser(user.id)
|
const privateUser = await getPrivateUser(user.id)
|
||||||
if (!privateUser) continue
|
if (!privateUser) continue
|
||||||
|
|
||||||
await sendMarketCloseEmail(user, privateUser, contract)
|
|
||||||
await createNotification(
|
await createNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
'contract',
|
'contract',
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { compact, uniq } from 'lodash'
|
import { compact } from 'lodash'
|
||||||
import { getContract, getUser, getValues } from './utils'
|
import { getContract, getUser, getValues } from './utils'
|
||||||
import { ContractComment } from '../../common/comment'
|
import { ContractComment } from '../../common/comment'
|
||||||
import { sendNewCommentEmail } from './emails'
|
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
import {
|
import {
|
||||||
createCommentOrAnswerOrUpdatedContractNotification,
|
createCommentOrAnswerOrUpdatedContractNotification,
|
||||||
filterUserIdsForOnlyFollowerIds,
|
replied_users_info,
|
||||||
} from './create-notification'
|
} from './create-notification'
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
import { addUserToContractFollowers } from './follow-market'
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
@ -77,16 +76,46 @@ export const onCreateCommentOnContract = functions
|
||||||
const comments = await getValues<ContractComment>(
|
const comments = await getValues<ContractComment>(
|
||||||
firestore.collection('contracts').doc(contractId).collection('comments')
|
firestore.collection('contracts').doc(contractId).collection('comments')
|
||||||
)
|
)
|
||||||
const relatedSourceType = comment.replyToCommentId
|
const repliedToType = answer
|
||||||
? 'comment'
|
|
||||||
: comment.answerOutcome
|
|
||||||
? 'answer'
|
? 'answer'
|
||||||
|
: comment.replyToCommentId
|
||||||
|
? 'comment'
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const repliedUserId = comment.replyToCommentId
|
const repliedUserId = comment.replyToCommentId
|
||||||
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
||||||
: answer?.userId
|
: answer?.userId
|
||||||
|
|
||||||
|
const mentionedUsers = compact(parseMentions(comment.content))
|
||||||
|
const repliedUsers: replied_users_info = {}
|
||||||
|
|
||||||
|
// The parent of the reply chain could be a comment or an answer
|
||||||
|
if (repliedUserId && repliedToType)
|
||||||
|
repliedUsers[repliedUserId] = {
|
||||||
|
repliedToType,
|
||||||
|
repliedToAnswerText: answer ? answer.text : undefined,
|
||||||
|
repliedToId: comment.replyToCommentId || answer?.id,
|
||||||
|
bet: bet,
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentsInSameReplyChain = comments.filter((c) =>
|
||||||
|
repliedToType === 'answer'
|
||||||
|
? c.answerOutcome === answer?.id
|
||||||
|
: repliedToType === 'comment'
|
||||||
|
? c.replyToCommentId === comment.replyToCommentId
|
||||||
|
: false
|
||||||
|
)
|
||||||
|
// The rest of the children in the chain are always comments
|
||||||
|
commentsInSameReplyChain.forEach((c) => {
|
||||||
|
if (c.userId !== comment.userId && c.userId !== repliedUserId) {
|
||||||
|
repliedUsers[c.userId] = {
|
||||||
|
repliedToType: 'comment',
|
||||||
|
repliedToAnswerText: undefined,
|
||||||
|
repliedToId: c.id,
|
||||||
|
bet: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
comment.id,
|
comment.id,
|
||||||
'comment',
|
'comment',
|
||||||
|
@ -96,31 +125,8 @@ export const onCreateCommentOnContract = functions
|
||||||
richTextToString(comment.content),
|
richTextToString(comment.content),
|
||||||
contract,
|
contract,
|
||||||
{
|
{
|
||||||
relatedSourceType,
|
repliedUsersInfo: repliedUsers,
|
||||||
repliedUserId,
|
taggedUserIds: mentionedUsers,
|
||||||
taggedUserIds: compact(parseMentions(comment.content)),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const recipientUserIds = await filterUserIdsForOnlyFollowerIds(
|
|
||||||
uniq([
|
|
||||||
contract.creatorId,
|
|
||||||
...comments.map((comment) => comment.userId),
|
|
||||||
]).filter((id) => id !== comment.userId),
|
|
||||||
contractId
|
|
||||||
)
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
recipientUserIds.map((userId) =>
|
|
||||||
sendNewCommentEmail(
|
|
||||||
userId,
|
|
||||||
commentCreator,
|
|
||||||
contract,
|
|
||||||
comment,
|
|
||||||
bet,
|
|
||||||
answer?.text,
|
|
||||||
answer?.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
|
|
||||||
import { getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createNewContractNotification } from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
|
@ -21,13 +21,11 @@ export const onCreateContract = functions
|
||||||
const mentioned = parseMentions(desc)
|
const mentioned = parseMentions(desc)
|
||||||
await addUserToContractFollowers(contract.id, contractCreator.id)
|
await addUserToContractFollowers(contract.id, contractCreator.id)
|
||||||
|
|
||||||
await createNotification(
|
await createNewContractNotification(
|
||||||
contract.id,
|
|
||||||
'contract',
|
|
||||||
'created',
|
|
||||||
contractCreator,
|
contractCreator,
|
||||||
|
contract,
|
||||||
eventId,
|
eventId,
|
||||||
richTextToString(desc),
|
richTextToString(desc),
|
||||||
{ contract, recipients: mentioned }
|
mentioned
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -13,32 +13,7 @@ export const onUpdateContract = functions.firestore
|
||||||
if (!contractUpdater) throw new Error('Could not find contract updater')
|
if (!contractUpdater) throw new Error('Could not find contract updater')
|
||||||
|
|
||||||
const previousValue = change.before.data() as Contract
|
const previousValue = change.before.data() as Contract
|
||||||
if (previousValue.isResolved !== contract.isResolved) {
|
if (
|
||||||
let resolutionText = contract.resolution ?? contract.question
|
|
||||||
if (contract.outcomeType === 'FREE_RESPONSE') {
|
|
||||||
const answerText = contract.answers.find(
|
|
||||||
(answer) => answer.id === contract.resolution
|
|
||||||
)?.text
|
|
||||||
if (answerText) resolutionText = answerText
|
|
||||||
} else if (contract.outcomeType === 'BINARY') {
|
|
||||||
if (resolutionText === 'MKT' && contract.resolutionProbability)
|
|
||||||
resolutionText = `${contract.resolutionProbability}%`
|
|
||||||
else if (resolutionText === 'MKT') resolutionText = 'PROB'
|
|
||||||
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
|
||||||
if (resolutionText === 'MKT' && contract.resolutionValue)
|
|
||||||
resolutionText = `${contract.resolutionValue}`
|
|
||||||
}
|
|
||||||
|
|
||||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
|
||||||
contract.id,
|
|
||||||
'contract',
|
|
||||||
'resolved',
|
|
||||||
contractUpdater,
|
|
||||||
eventId,
|
|
||||||
resolutionText,
|
|
||||||
contract
|
|
||||||
)
|
|
||||||
} else if (
|
|
||||||
previousValue.closeTime !== contract.closeTime ||
|
previousValue.closeTime !== contract.closeTime ||
|
||||||
previousValue.question !== contract.question
|
previousValue.question !== contract.question
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { difference, mapValues, groupBy, sumBy } from 'lodash'
|
import { mapValues, groupBy, sumBy } from 'lodash'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
|
@ -8,10 +8,8 @@ import {
|
||||||
MultipleChoiceContract,
|
MultipleChoiceContract,
|
||||||
RESOLUTIONS,
|
RESOLUTIONS,
|
||||||
} from '../../common/contract'
|
} from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { getUser, isProd, payUser } from './utils'
|
import { getUser, isProd, payUser } from './utils'
|
||||||
import { sendMarketResolutionEmail } from './emails'
|
|
||||||
import {
|
import {
|
||||||
getLoanPayouts,
|
getLoanPayouts,
|
||||||
getPayouts,
|
getPayouts,
|
||||||
|
@ -23,7 +21,7 @@ import { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { getContractBetMetrics } from '../../common/calculate'
|
import { getContractBetMetrics } from '../../common/calculate'
|
||||||
import { floatingEqual } from '../../common/util/math'
|
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
|
@ -163,15 +161,48 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
|
|
||||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
||||||
|
|
||||||
await sendResolutionEmails(
|
const userInvestments = mapValues(
|
||||||
bets,
|
groupBy(bets, (bet) => bet.userId),
|
||||||
userPayoutsWithoutLoans,
|
(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,
|
creator,
|
||||||
creatorPayout,
|
contract.id + '-resolution',
|
||||||
|
resolutionText,
|
||||||
contract,
|
contract,
|
||||||
outcome,
|
undefined,
|
||||||
resolutionProbability,
|
{
|
||||||
resolutions
|
bets,
|
||||||
|
userInvestments,
|
||||||
|
userPayouts: userPayoutsWithoutLoans,
|
||||||
|
creator,
|
||||||
|
creatorPayout,
|
||||||
|
contract,
|
||||||
|
outcome,
|
||||||
|
resolutionProbability,
|
||||||
|
resolutions,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return updatedContract
|
return updatedContract
|
||||||
|
@ -189,51 +220,6 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => {
|
||||||
.then(() => ({ status: 'success' }))
|
.then(() => ({ status: 'success' }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendResolutionEmails = async (
|
|
||||||
bets: Bet[],
|
|
||||||
userPayouts: { [userId: string]: number },
|
|
||||||
creator: User,
|
|
||||||
creatorPayout: number,
|
|
||||||
contract: Contract,
|
|
||||||
outcome: string,
|
|
||||||
resolutionProbability?: number,
|
|
||||||
resolutions?: { [outcome: string]: number }
|
|
||||||
) => {
|
|
||||||
const investedByUser = mapValues(
|
|
||||||
groupBy(bets, (bet) => bet.userId),
|
|
||||||
(bets) => getContractBetMetrics(contract, bets).invested
|
|
||||||
)
|
|
||||||
const investedUsers = Object.keys(investedByUser).filter(
|
|
||||||
(userId) => !floatingEqual(investedByUser[userId], 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
const nonWinners = difference(investedUsers, Object.keys(userPayouts))
|
|
||||||
const emailPayouts = [
|
|
||||||
...Object.entries(userPayouts),
|
|
||||||
...nonWinners.map((userId) => [userId, 0] as const),
|
|
||||||
].map(([userId, payout]) => ({
|
|
||||||
userId,
|
|
||||||
investment: investedByUser[userId] ?? 0,
|
|
||||||
payout,
|
|
||||||
}))
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
emailPayouts.map(({ userId, investment, payout }) =>
|
|
||||||
sendMarketResolutionEmail(
|
|
||||||
userId,
|
|
||||||
investment,
|
|
||||||
payout,
|
|
||||||
creator,
|
|
||||||
creatorPayout,
|
|
||||||
contract,
|
|
||||||
outcome,
|
|
||||||
resolutionProbability,
|
|
||||||
resolutions
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getResolutionParams(contract: Contract, body: string) {
|
function getResolutionParams(contract: Contract, body: string) {
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
|
|
||||||
|
|
30
functions/src/scripts/create-new-notification-preferences.ts
Normal file
30
functions/src/scripts/create-new-notification-preferences.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import { getDefaultNotificationSettings } from 'common/user'
|
||||||
|
import { getAllPrivateUsers, isProd } from 'functions/src/utils'
|
||||||
|
initAdmin()
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const privateUsers = await getAllPrivateUsers()
|
||||||
|
const disableEmails = !isProd()
|
||||||
|
await Promise.all(
|
||||||
|
privateUsers.map((privateUser) => {
|
||||||
|
if (!privateUser.id) return Promise.resolve()
|
||||||
|
return firestore
|
||||||
|
.collection('private-users')
|
||||||
|
.doc(privateUser.id)
|
||||||
|
.update({
|
||||||
|
notificationSubscriptionTypes: getDefaultNotificationSettings(
|
||||||
|
privateUser.id,
|
||||||
|
privateUser,
|
||||||
|
disableEmails
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main().then(() => process.exit())
|
|
@ -3,7 +3,7 @@ import * as admin from 'firebase-admin'
|
||||||
import { initAdmin } from './script-init'
|
import { initAdmin } from './script-init'
|
||||||
initAdmin()
|
initAdmin()
|
||||||
|
|
||||||
import { PrivateUser, User } from 'common/user'
|
import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user'
|
||||||
import { STARTING_BALANCE } from 'common/economy'
|
import { STARTING_BALANCE } from 'common/economy'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
@ -21,6 +21,7 @@ async function main() {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
|
notificationSubscriptionTypes: getDefaultNotificationSettings(user.id),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.totalDeposits === undefined) {
|
if (user.totalDeposits === undefined) {
|
||||||
|
|
|
@ -4,9 +4,11 @@ import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
|
||||||
import { getValues, log, logMemory, writeAsync } from './utils'
|
import { getValues, log, logMemory, writeAsync } from './utils'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Contract, CPMM } from '../../common/contract'
|
import { Contract, CPMM } from '../../common/contract'
|
||||||
|
|
||||||
import { PortfolioMetrics, User } from '../../common/user'
|
import { PortfolioMetrics, User } from '../../common/user'
|
||||||
import { DAY_MS } from '../../common/util/time'
|
import { DAY_MS } from '../../common/util/time'
|
||||||
import { getLoanUpdates } from '../../common/loans'
|
import { getLoanUpdates } from '../../common/loans'
|
||||||
|
import { scoreTraders, scoreCreators } from '../../common/scoring'
|
||||||
import {
|
import {
|
||||||
calculateCreatorVolume,
|
calculateCreatorVolume,
|
||||||
calculateNewPortfolioMetrics,
|
calculateNewPortfolioMetrics,
|
||||||
|
@ -15,6 +17,7 @@ import {
|
||||||
computeVolume,
|
computeVolume,
|
||||||
} from '../../common/calculate-metrics'
|
} from '../../common/calculate-metrics'
|
||||||
import { getProbability } from '../../common/calculate'
|
import { getProbability } from '../../common/calculate'
|
||||||
|
import { Group } from 'common/group'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
@ -24,16 +27,29 @@ export const updateMetrics = functions
|
||||||
.onRun(updateMetricsCore)
|
.onRun(updateMetricsCore)
|
||||||
|
|
||||||
export async function updateMetricsCore() {
|
export async function updateMetricsCore() {
|
||||||
const [users, contracts, bets, allPortfolioHistories] = await Promise.all([
|
const [users, contracts, bets, allPortfolioHistories, groups] =
|
||||||
getValues<User>(firestore.collection('users')),
|
await Promise.all([
|
||||||
getValues<Contract>(firestore.collection('contracts')),
|
getValues<User>(firestore.collection('users')),
|
||||||
getValues<Bet>(firestore.collectionGroup('bets')),
|
getValues<Contract>(firestore.collection('contracts')),
|
||||||
getValues<PortfolioMetrics>(
|
getValues<Bet>(firestore.collectionGroup('bets')),
|
||||||
firestore
|
getValues<PortfolioMetrics>(
|
||||||
.collectionGroup('portfolioHistory')
|
firestore
|
||||||
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
|
.collectionGroup('portfolioHistory')
|
||||||
),
|
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
|
||||||
])
|
),
|
||||||
|
getValues<Group>(firestore.collection('groups')),
|
||||||
|
])
|
||||||
|
|
||||||
|
const contractsByGroup = await Promise.all(
|
||||||
|
groups.map((group) => {
|
||||||
|
return getValues(
|
||||||
|
firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(group.id)
|
||||||
|
.collection('groupContracts')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
log(
|
log(
|
||||||
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
|
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
|
||||||
)
|
)
|
||||||
|
@ -41,6 +57,7 @@ export async function updateMetricsCore() {
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const betsByContract = groupBy(bets, (bet) => bet.contractId)
|
const betsByContract = groupBy(bets, (bet) => bet.contractId)
|
||||||
|
|
||||||
const contractUpdates = contracts
|
const contractUpdates = contracts
|
||||||
.filter((contract) => contract.id)
|
.filter((contract) => contract.id)
|
||||||
.map((contract) => {
|
.map((contract) => {
|
||||||
|
@ -162,4 +179,48 @@ export async function updateMetricsCore() {
|
||||||
'set'
|
'set'
|
||||||
)
|
)
|
||||||
log(`Updated metrics for ${users.length} users.`)
|
log(`Updated metrics for ${users.length} users.`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groupUpdates = groups.map((group, index) => {
|
||||||
|
const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
|
||||||
|
const groupContracts = groupContractIds
|
||||||
|
.map((e) => contractsById[e.contractId])
|
||||||
|
.filter((e) => e !== undefined) as Contract[]
|
||||||
|
const bets = groupContracts.map((e) => {
|
||||||
|
if (e != null && e.id in betsByContract) {
|
||||||
|
return betsByContract[e.id] ?? []
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const creatorScores = scoreCreators(groupContracts)
|
||||||
|
const traderScores = scoreTraders(groupContracts, bets)
|
||||||
|
|
||||||
|
const topTraderScores = topUserScores(traderScores)
|
||||||
|
const topCreatorScores = topUserScores(creatorScores)
|
||||||
|
|
||||||
|
return {
|
||||||
|
doc: firestore.collection('groups').doc(group.id),
|
||||||
|
fields: {
|
||||||
|
cachedLeaderboard: {
|
||||||
|
topTraders: topTraderScores,
|
||||||
|
topCreators: topCreatorScores,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await writeAsync(firestore, groupUpdates)
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error While Updating Group Leaderboards', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const topUserScores = (scores: { [userId: string]: number }) => {
|
||||||
|
const top50 = Object.entries(scores)
|
||||||
|
.sort(([, scoreA], [, scoreB]) => scoreB - scoreA)
|
||||||
|
.slice(0, 50)
|
||||||
|
return top50.map(([userId, score]) => ({ userId, score }))
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupContractDoc = { contractId: string; createdTime: number }
|
||||||
|
|
|
@ -1,236 +0,0 @@
|
||||||
import { useUser } from 'web/hooks/use-user'
|
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { notification_subscribe_types, PrivateUser } from 'common/user'
|
|
||||||
import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
import { track } from '@amplitude/analytics-browser'
|
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
|
||||||
import { Row } from 'web/components/layout/row'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
|
||||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
|
||||||
import { Col } from 'web/components/layout/col'
|
|
||||||
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
|
|
||||||
|
|
||||||
export function NotificationSettings() {
|
|
||||||
const user = useUser()
|
|
||||||
const [notificationSettings, setNotificationSettings] =
|
|
||||||
useState<notification_subscribe_types>('all')
|
|
||||||
const [emailNotificationSettings, setEmailNotificationSettings] =
|
|
||||||
useState<notification_subscribe_types>('all')
|
|
||||||
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
|
||||||
const [showModal, setShowModal] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) listenForPrivateUser(user.id, setPrivateUser)
|
|
||||||
}, [user])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!privateUser) return
|
|
||||||
if (privateUser.notificationPreferences) {
|
|
||||||
setNotificationSettings(privateUser.notificationPreferences)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
privateUser.unsubscribedFromResolutionEmails &&
|
|
||||||
privateUser.unsubscribedFromCommentEmails &&
|
|
||||||
privateUser.unsubscribedFromAnswerEmails
|
|
||||||
) {
|
|
||||||
setEmailNotificationSettings('none')
|
|
||||||
} else if (
|
|
||||||
!privateUser.unsubscribedFromResolutionEmails &&
|
|
||||||
!privateUser.unsubscribedFromCommentEmails &&
|
|
||||||
!privateUser.unsubscribedFromAnswerEmails
|
|
||||||
) {
|
|
||||||
setEmailNotificationSettings('all')
|
|
||||||
} else {
|
|
||||||
setEmailNotificationSettings('less')
|
|
||||||
}
|
|
||||||
}, [privateUser])
|
|
||||||
|
|
||||||
const loading = 'Changing Notifications Settings'
|
|
||||||
const success = 'Notification Settings Changed!'
|
|
||||||
function changeEmailNotifications(newValue: notification_subscribe_types) {
|
|
||||||
if (!privateUser) return
|
|
||||||
if (newValue === 'all') {
|
|
||||||
toast.promise(
|
|
||||||
updatePrivateUser(privateUser.id, {
|
|
||||||
unsubscribedFromResolutionEmails: false,
|
|
||||||
unsubscribedFromCommentEmails: false,
|
|
||||||
unsubscribedFromAnswerEmails: false,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
loading,
|
|
||||||
success,
|
|
||||||
error: (err) => `${err.message}`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if (newValue === 'less') {
|
|
||||||
toast.promise(
|
|
||||||
updatePrivateUser(privateUser.id, {
|
|
||||||
unsubscribedFromResolutionEmails: false,
|
|
||||||
unsubscribedFromCommentEmails: true,
|
|
||||||
unsubscribedFromAnswerEmails: true,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
loading,
|
|
||||||
success,
|
|
||||||
error: (err) => `${err.message}`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if (newValue === 'none') {
|
|
||||||
toast.promise(
|
|
||||||
updatePrivateUser(privateUser.id, {
|
|
||||||
unsubscribedFromResolutionEmails: true,
|
|
||||||
unsubscribedFromCommentEmails: true,
|
|
||||||
unsubscribedFromAnswerEmails: true,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
loading,
|
|
||||||
success,
|
|
||||||
error: (err) => `${err.message}`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeInAppNotificationSettings(
|
|
||||||
newValue: notification_subscribe_types
|
|
||||||
) {
|
|
||||||
if (!privateUser) return
|
|
||||||
track('In-App Notification Preferences Changed', {
|
|
||||||
newPreference: newValue,
|
|
||||||
oldPreference: privateUser.notificationPreferences,
|
|
||||||
})
|
|
||||||
toast.promise(
|
|
||||||
updatePrivateUser(privateUser.id, {
|
|
||||||
notificationPreferences: newValue,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
loading,
|
|
||||||
success,
|
|
||||||
error: (err) => `${err.message}`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (privateUser && privateUser.notificationPreferences)
|
|
||||||
setNotificationSettings(privateUser.notificationPreferences)
|
|
||||||
else setNotificationSettings('all')
|
|
||||||
}, [privateUser])
|
|
||||||
|
|
||||||
if (!privateUser) {
|
|
||||||
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationSettingLine(props: {
|
|
||||||
label: string | React.ReactNode
|
|
||||||
highlight: boolean
|
|
||||||
onClick?: () => void
|
|
||||||
}) {
|
|
||||||
const { label, highlight, onClick } = props
|
|
||||||
return (
|
|
||||||
<Row
|
|
||||||
className={clsx(
|
|
||||||
'my-1 gap-1 text-gray-300',
|
|
||||||
highlight && '!text-black',
|
|
||||||
onClick ? 'cursor-pointer' : ''
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
|
|
||||||
{label}
|
|
||||||
</Row>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={'p-2'}>
|
|
||||||
<div>In App Notifications</div>
|
|
||||||
<ChoicesToggleGroup
|
|
||||||
currentChoice={notificationSettings}
|
|
||||||
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
|
|
||||||
setChoice={(choice) =>
|
|
||||||
changeInAppNotificationSettings(
|
|
||||||
choice as notification_subscribe_types
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={'col-span-4 p-2'}
|
|
||||||
toggleClassName={'w-24'}
|
|
||||||
/>
|
|
||||||
<div className={'mt-4 text-sm'}>
|
|
||||||
<Col className={''}>
|
|
||||||
<Row className={'my-1'}>
|
|
||||||
You will receive notifications for these general events:
|
|
||||||
</Row>
|
|
||||||
<NotificationSettingLine
|
|
||||||
highlight={notificationSettings !== 'none'}
|
|
||||||
label={"Income & referral bonuses you've received"}
|
|
||||||
/>
|
|
||||||
<Row className={'my-1'}>
|
|
||||||
You will receive new comment, answer, & resolution notifications on
|
|
||||||
questions:
|
|
||||||
</Row>
|
|
||||||
<NotificationSettingLine
|
|
||||||
highlight={notificationSettings !== 'none'}
|
|
||||||
label={
|
|
||||||
<span>
|
|
||||||
That <span className={'font-bold'}>you watch </span>- you
|
|
||||||
auto-watch questions if:
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
onClick={() => setShowModal(true)}
|
|
||||||
/>
|
|
||||||
<Col
|
|
||||||
className={clsx(
|
|
||||||
'mb-2 ml-8',
|
|
||||||
'gap-1 text-gray-300',
|
|
||||||
notificationSettings !== 'none' && '!text-black'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Row>• You create it</Row>
|
|
||||||
<Row>• You bet, comment on, or answer it</Row>
|
|
||||||
<Row>• You add liquidity to it</Row>
|
|
||||||
<Row>
|
|
||||||
• If you select 'Less' and you've commented on or answered a
|
|
||||||
question, you'll only receive notification on direct replies to
|
|
||||||
your comments or answers
|
|
||||||
</Row>
|
|
||||||
</Col>
|
|
||||||
</Col>
|
|
||||||
</div>
|
|
||||||
<div className={'mt-4'}>Email Notifications</div>
|
|
||||||
<ChoicesToggleGroup
|
|
||||||
currentChoice={emailNotificationSettings}
|
|
||||||
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
|
|
||||||
setChoice={(choice) =>
|
|
||||||
changeEmailNotifications(choice as notification_subscribe_types)
|
|
||||||
}
|
|
||||||
className={'col-span-4 p-2'}
|
|
||||||
toggleClassName={'w-24'}
|
|
||||||
/>
|
|
||||||
<div className={'mt-4 text-sm'}>
|
|
||||||
<div>
|
|
||||||
You will receive emails for:
|
|
||||||
<NotificationSettingLine
|
|
||||||
label={"Resolution of questions you're betting on"}
|
|
||||||
highlight={emailNotificationSettings !== 'none'}
|
|
||||||
/>
|
|
||||||
<NotificationSettingLine
|
|
||||||
label={'Closure of your questions'}
|
|
||||||
highlight={emailNotificationSettings !== 'none'}
|
|
||||||
/>
|
|
||||||
<NotificationSettingLine
|
|
||||||
label={'Activity on your questions'}
|
|
||||||
highlight={emailNotificationSettings === 'all'}
|
|
||||||
/>
|
|
||||||
<NotificationSettingLine
|
|
||||||
label={"Activity on questions you've answered or commented on"}
|
|
||||||
highlight={emailNotificationSettings === 'all'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FollowMarketModal setOpen={setShowModal} open={showModal} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -122,6 +122,18 @@ export function BuyAmountInput(props: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseRaw = (x: number) => {
|
||||||
|
if (x <= 100) return x
|
||||||
|
if (x <= 130) return 100 + (x - 100) * 5
|
||||||
|
return 250 + (x - 130) * 10
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRaw = (x: number) => {
|
||||||
|
if (x <= 100) return x
|
||||||
|
if (x <= 250) return 100 + (x - 100) / 5
|
||||||
|
return 130 + (x - 250) / 10
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AmountInput
|
<AmountInput
|
||||||
|
@ -138,10 +150,10 @@ export function BuyAmountInput(props: {
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max="200"
|
max="205"
|
||||||
value={amount ?? 0}
|
value={getRaw(amount ?? 0)}
|
||||||
onChange={(e) => onAmountChange(parseInt(e.target.value))}
|
onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
|
||||||
className="range range-lg z-40 mb-2 xl:hidden"
|
className="range range-lg only-thumb z-40 mb-2 xl:hidden"
|
||||||
step="5"
|
step="5"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { XIcon } from '@heroicons/react/solid'
|
import { XIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
import { Answer } from 'common/answer'
|
import { Answer } from 'common/answer'
|
||||||
|
@ -25,8 +25,7 @@ import {
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { BetSignUpPrompt } from '../sign-up-prompt'
|
import { BetSignUpPrompt } from '../sign-up-prompt'
|
||||||
import { isIOS } from 'web/lib/util/device'
|
import { WarningConfirmationButton } from '../warning-confirmation-button'
|
||||||
import { AlertBox } from '../alert-box'
|
|
||||||
|
|
||||||
export function AnswerBetPanel(props: {
|
export function AnswerBetPanel(props: {
|
||||||
answer: Answer
|
answer: Answer
|
||||||
|
@ -44,12 +43,6 @@ export function AnswerBetPanel(props: {
|
||||||
const [error, setError] = useState<string | undefined>()
|
const [error, setError] = useState<string | undefined>()
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
const inputRef = useRef<HTMLElement>(null)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isIOS()) window.scrollTo(0, window.scrollY + 200)
|
|
||||||
inputRef.current && inputRef.current.focus()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
async function submitBet() {
|
async function submitBet() {
|
||||||
if (!user || !betAmount) return
|
if (!user || !betAmount) return
|
||||||
|
|
||||||
|
@ -116,6 +109,15 @@ export function AnswerBetPanel(props: {
|
||||||
|
|
||||||
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
||||||
|
|
||||||
|
const warning =
|
||||||
|
(betAmount ?? 0) >= 100 && bankrollFraction >= 0.5 && bankrollFraction <= 1
|
||||||
|
? `You might not want to spend ${formatPercent(
|
||||||
|
bankrollFraction
|
||||||
|
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
|
||||||
|
user?.balance ?? 0
|
||||||
|
)}`
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
|
<Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
|
||||||
<Row className="items-center justify-between self-stretch">
|
<Row className="items-center justify-between self-stretch">
|
||||||
|
@ -144,25 +146,9 @@ export function AnswerBetPanel(props: {
|
||||||
error={error}
|
error={error}
|
||||||
setError={setError}
|
setError={setError}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
inputRef={inputRef}
|
|
||||||
showSliderOnMobile
|
showSliderOnMobile
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(betAmount ?? 0) > 10 &&
|
|
||||||
bankrollFraction >= 0.5 &&
|
|
||||||
bankrollFraction <= 1 ? (
|
|
||||||
<AlertBox
|
|
||||||
title="Whoa, there!"
|
|
||||||
text={`You might not want to spend ${formatPercent(
|
|
||||||
bankrollFraction
|
|
||||||
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
|
|
||||||
user?.balance ?? 0
|
|
||||||
)}`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Col className="mt-3 w-full gap-3">
|
<Col className="mt-3 w-full gap-3">
|
||||||
<Row className="items-center justify-between text-sm">
|
<Row className="items-center justify-between text-sm">
|
||||||
<div className="text-gray-500">Probability</div>
|
<div className="text-gray-500">Probability</div>
|
||||||
|
@ -198,16 +184,17 @@ export function AnswerBetPanel(props: {
|
||||||
<Spacer h={6} />
|
<Spacer h={6} />
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<button
|
<WarningConfirmationButton
|
||||||
className={clsx(
|
warning={warning}
|
||||||
|
onSubmit={submitBet}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
disabled={!!betDisabled}
|
||||||
|
openModalButtonClass={clsx(
|
||||||
'btn self-stretch',
|
'btn self-stretch',
|
||||||
betDisabled ? 'btn-disabled' : 'btn-primary',
|
betDisabled ? 'btn-disabled' : 'btn-primary',
|
||||||
isSubmitting ? 'loading' : ''
|
isSubmitting ? 'loading' : ''
|
||||||
)}
|
)}
|
||||||
onClick={betDisabled ? undefined : submitBet}
|
/>
|
||||||
>
|
|
||||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<BetSignUpPrompt />
|
<BetSignUpPrompt />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -23,6 +23,7 @@ import { Avatar } from 'web/components/avatar'
|
||||||
import { Linkify } from 'web/components/linkify'
|
import { Linkify } from 'web/components/linkify'
|
||||||
import { BuyButton } from 'web/components/yes-no-selector'
|
import { BuyButton } from 'web/components/yes-no-selector'
|
||||||
import { UserLink } from 'web/components/user-link'
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
import { Button } from 'web/components/button'
|
||||||
|
|
||||||
export function AnswersPanel(props: {
|
export function AnswersPanel(props: {
|
||||||
contract: FreeResponseContract | MultipleChoiceContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
|
@ -30,14 +31,15 @@ export function AnswersPanel(props: {
|
||||||
const { contract } = props
|
const { contract } = props
|
||||||
const { creatorId, resolution, resolutions, totalBets, outcomeType } =
|
const { creatorId, resolution, resolutions, totalBets, outcomeType } =
|
||||||
contract
|
contract
|
||||||
|
const [showAllAnswers, setShowAllAnswers] = useState(false)
|
||||||
|
|
||||||
|
const answers = (useAnswers(contract.id) ?? contract.answers).filter(
|
||||||
|
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
|
||||||
|
)
|
||||||
|
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1)
|
||||||
|
|
||||||
const answers = useAnswers(contract.id) ?? contract.answers
|
|
||||||
const [winningAnswers, losingAnswers] = partition(
|
const [winningAnswers, losingAnswers] = partition(
|
||||||
answers.filter(
|
answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)),
|
||||||
(answer) =>
|
|
||||||
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
|
|
||||||
totalBets[answer.id] > 0.000000001
|
|
||||||
),
|
|
||||||
(answer) =>
|
(answer) =>
|
||||||
answer.id === resolution || (resolutions && resolutions[answer.id])
|
answer.id === resolution || (resolutions && resolutions[answer.id])
|
||||||
)
|
)
|
||||||
|
@ -127,6 +129,17 @@ export function AnswersPanel(props: {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<Row className={'justify-end'}>
|
||||||
|
{hasZeroBetAnswers && !showAllAnswers && (
|
||||||
|
<Button
|
||||||
|
color={'gray-white'}
|
||||||
|
onClick={() => setShowAllAnswers(true)}
|
||||||
|
size={'md'}
|
||||||
|
>
|
||||||
|
Show More
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -194,7 +207,7 @@ function OpenAnswer(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={'border-base-200 bg-base-200 flex-1 rounded-md px-2'}>
|
<Col className={'border-base-200 bg-base-200 flex-1 rounded-md px-2'}>
|
||||||
<Modal open={open} setOpen={setOpen}>
|
<Modal open={open} setOpen={setOpen} position="center">
|
||||||
<AnswerBetPanel
|
<AnswerBetPanel
|
||||||
answer={answer}
|
answer={answer}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
|
|
@ -76,7 +76,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
||||||
if (existingAnswer) {
|
if (existingAnswer) {
|
||||||
setAnswerError(
|
setAnswerError(
|
||||||
existingAnswer
|
existingAnswer
|
||||||
? `"${existingAnswer.text}" already exists as an answer`
|
? `"${existingAnswer.text}" already exists as an answer. Can't see it? Hit the 'Show More' button right above this box.`
|
||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
@ -237,7 +237,7 @@ const AnswerError = (props: { text: string; level: answerErrorLevel }) => {
|
||||||
}[level] ?? ''
|
}[level] ?? ''
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${colorClass} mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide`}
|
className={`${colorClass} mb-2 mr-auto self-center text-xs font-medium tracking-wide`}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,25 +7,19 @@ import { Row } from 'web/components/layout/row'
|
||||||
import { Subtitle } from 'web/components/subtitle'
|
import { Subtitle } from 'web/components/subtitle'
|
||||||
import { useMemberGroups } from 'web/hooks/use-group'
|
import { useMemberGroups } from 'web/hooks/use-group'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
import { keyBy } from 'lodash'
|
import { isArray, keyBy } from 'lodash'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
|
|
||||||
export function ArrangeHome(props: {
|
export function ArrangeHome(props: {
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
homeSections: { visible: string[]; hidden: string[] }
|
homeSections: string[]
|
||||||
setHomeSections: (homeSections: {
|
setHomeSections: (sections: string[]) => void
|
||||||
visible: string[]
|
|
||||||
hidden: string[]
|
|
||||||
}) => void
|
|
||||||
}) {
|
}) {
|
||||||
const { user, homeSections, setHomeSections } = props
|
const { user, homeSections, setHomeSections } = props
|
||||||
|
|
||||||
const groups = useMemberGroups(user?.id) ?? []
|
const groups = useMemberGroups(user?.id) ?? []
|
||||||
const { itemsById, visibleItems, hiddenItems } = getHomeItems(
|
const { itemsById, sections } = getHomeItems(groups, homeSections)
|
||||||
groups,
|
|
||||||
homeSections
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropContext
|
<DragDropContext
|
||||||
|
@ -35,23 +29,16 @@ export function ArrangeHome(props: {
|
||||||
|
|
||||||
const item = itemsById[draggableId]
|
const item = itemsById[draggableId]
|
||||||
|
|
||||||
const newHomeSections = {
|
const newHomeSections = sections.map((section) => section.id)
|
||||||
visible: visibleItems.map((item) => item.id),
|
|
||||||
hidden: hiddenItems.map((item) => item.id),
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceSection = source.droppableId as 'visible' | 'hidden'
|
newHomeSections.splice(source.index, 1)
|
||||||
newHomeSections[sourceSection].splice(source.index, 1)
|
newHomeSections.splice(destination.index, 0, item.id)
|
||||||
|
|
||||||
const destSection = destination.droppableId as 'visible' | 'hidden'
|
|
||||||
newHomeSections[destSection].splice(destination.index, 0, item.id)
|
|
||||||
|
|
||||||
setHomeSections(newHomeSections)
|
setHomeSections(newHomeSections)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Row className="relative max-w-lg gap-4">
|
<Row className="relative max-w-md gap-4">
|
||||||
<DraggableList items={visibleItems} title="Visible" />
|
<DraggableList items={sections} title="Sections" />
|
||||||
<DraggableList items={hiddenItems} title="Hidden" />
|
|
||||||
</Row>
|
</Row>
|
||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
)
|
)
|
||||||
|
@ -64,16 +51,13 @@ function DraggableList(props: {
|
||||||
const { title, items } = props
|
const { title, items } = props
|
||||||
return (
|
return (
|
||||||
<Droppable droppableId={title.toLowerCase()}>
|
<Droppable droppableId={title.toLowerCase()}>
|
||||||
{(provided, snapshot) => (
|
{(provided) => (
|
||||||
<Col
|
<Col
|
||||||
{...provided.droppableProps}
|
{...provided.droppableProps}
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
className={clsx(
|
className={clsx('flex-1 items-stretch gap-1 rounded bg-gray-100 p-4')}
|
||||||
'width-[220px] flex-1 items-start rounded bg-gray-50 p-2',
|
|
||||||
snapshot.isDraggingOver && 'bg-gray-100'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Subtitle text={title} className="mx-2 !my-2" />
|
<Subtitle text={title} className="mx-2 !mt-0 !mb-4" />
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<Draggable key={item.id} draggableId={item.id} index={index}>
|
<Draggable key={item.id} draggableId={item.id} index={index}>
|
||||||
{(provided, snapshot) => (
|
{(provided, snapshot) => (
|
||||||
|
@ -82,16 +66,13 @@ function DraggableList(props: {
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
style={provided.draggableProps.style}
|
style={provided.draggableProps.style}
|
||||||
className={clsx(
|
|
||||||
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2',
|
|
||||||
snapshot.isDragging && 'z-[9000] bg-gray-300'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<MenuIcon
|
<SectionItem
|
||||||
className="h-5 w-5 flex-shrink-0 text-gray-500"
|
className={clsx(
|
||||||
aria-hidden="true"
|
snapshot.isDragging && 'z-[9000] bg-gray-200'
|
||||||
/>{' '}
|
)}
|
||||||
{item.label}
|
item={item}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Draggable>
|
</Draggable>
|
||||||
|
@ -103,15 +84,36 @@ function DraggableList(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getHomeItems = (
|
const SectionItem = (props: {
|
||||||
groups: Group[],
|
item: { id: string; label: string }
|
||||||
homeSections: { visible: string[]; hidden: string[] }
|
className?: string
|
||||||
) => {
|
}) => {
|
||||||
|
const { item, className } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MenuIcon
|
||||||
|
className="h-5 w-5 flex-shrink-0 text-gray-500"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{' '}
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getHomeItems = (groups: Group[], sections: string[]) => {
|
||||||
|
// Accommodate old home sections.
|
||||||
|
if (!isArray(sections)) sections = []
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
|
{ label: 'Daily movers', id: 'daily-movers' },
|
||||||
{ label: 'Trending', id: 'score' },
|
{ label: 'Trending', id: 'score' },
|
||||||
{ label: 'Newest', id: 'newest' },
|
{ label: 'New for you', id: 'newest' },
|
||||||
{ label: 'Close date', id: 'close-date' },
|
|
||||||
{ label: 'Your trades', id: 'your-bets' },
|
|
||||||
...groups.map((g) => ({
|
...groups.map((g) => ({
|
||||||
label: g.name,
|
label: g.name,
|
||||||
id: g.id,
|
id: g.id,
|
||||||
|
@ -119,23 +121,13 @@ export const getHomeItems = (
|
||||||
]
|
]
|
||||||
const itemsById = keyBy(items, 'id')
|
const itemsById = keyBy(items, 'id')
|
||||||
|
|
||||||
const { visible, hidden } = homeSections
|
const sectionItems = filterDefined(sections.map((id) => itemsById[id]))
|
||||||
|
|
||||||
const [visibleItems, hiddenItems] = [
|
// Add unmentioned items to the end.
|
||||||
filterDefined(visible.map((id) => itemsById[id])),
|
sectionItems.push(...items.filter((item) => !sectionItems.includes(item)))
|
||||||
filterDefined(hidden.map((id) => itemsById[id])),
|
|
||||||
]
|
|
||||||
|
|
||||||
// Add unmentioned items to the visible list.
|
|
||||||
visibleItems.push(
|
|
||||||
...items.filter(
|
|
||||||
(item) => !visibleItems.includes(item) && !hiddenItems.includes(item)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
visibleItems,
|
sections: sectionItems,
|
||||||
hiddenItems,
|
|
||||||
itemsById,
|
itemsById,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,7 +60,7 @@ export default function BetButton(props: {
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Modal open={open} setOpen={setOpen}>
|
<Modal open={open} setOpen={setOpen} position="center">
|
||||||
<SimpleBetPanel
|
<SimpleBetPanel
|
||||||
className={betPanelClassName}
|
className={betPanelClassName}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
|
|
@ -40,7 +40,9 @@ import { LimitBets } from './limit-bets'
|
||||||
import { PillButton } from './buttons/pill-button'
|
import { PillButton } from './buttons/pill-button'
|
||||||
import { YesNoSelector } from './yes-no-selector'
|
import { YesNoSelector } from './yes-no-selector'
|
||||||
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
|
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
|
||||||
import { AlertBox } from './alert-box'
|
import { isAndroid, isIOS } from 'web/lib/util/device'
|
||||||
|
import { WarningConfirmationButton } from './warning-confirmation-button'
|
||||||
|
import { MarketIntroPanel } from './market-intro-panel'
|
||||||
|
|
||||||
export function BetPanel(props: {
|
export function BetPanel(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
|
@ -89,10 +91,7 @@ export function BetPanel(props: {
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<MarketIntroPanel />
|
||||||
<BetSignUpPrompt />
|
|
||||||
<PlayMoneyDisclaimer />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
@ -184,17 +183,13 @@ function BuyPanel(props: {
|
||||||
|
|
||||||
const [inputRef, focusAmountInput] = useFocus()
|
const [inputRef, focusAmountInput] = useFocus()
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// if (selected) {
|
|
||||||
// if (isIOS()) window.scrollTo(0, window.scrollY + 200)
|
|
||||||
// focusAmountInput()
|
|
||||||
// }
|
|
||||||
// }, [selected, focusAmountInput])
|
|
||||||
|
|
||||||
function onBetChoice(choice: 'YES' | 'NO') {
|
function onBetChoice(choice: 'YES' | 'NO') {
|
||||||
setOutcome(choice)
|
setOutcome(choice)
|
||||||
setWasSubmitted(false)
|
setWasSubmitted(false)
|
||||||
focusAmountInput()
|
|
||||||
|
if (!isIOS() && !isAndroid()) {
|
||||||
|
focusAmountInput()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBetChange(newAmount: number | undefined) {
|
function onBetChange(newAmount: number | undefined) {
|
||||||
|
@ -274,25 +269,15 @@ function BuyPanel(props: {
|
||||||
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
||||||
|
|
||||||
const warning =
|
const warning =
|
||||||
(betAmount ?? 0) > 10 &&
|
(betAmount ?? 0) >= 100 && bankrollFraction >= 0.5 && bankrollFraction <= 1
|
||||||
bankrollFraction >= 0.5 &&
|
? `You might not want to spend ${formatPercent(
|
||||||
bankrollFraction <= 1 ? (
|
|
||||||
<AlertBox
|
|
||||||
title="Whoa, there!"
|
|
||||||
text={`You might not want to spend ${formatPercent(
|
|
||||||
bankrollFraction
|
bankrollFraction
|
||||||
)} of your balance on a single trade. \n\nCurrent balance: ${formatMoney(
|
)} of your balance on a single trade. \n\nCurrent balance: ${formatMoney(
|
||||||
user?.balance ?? 0
|
user?.balance ?? 0
|
||||||
)}`}
|
)}`
|
||||||
/>
|
: (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1
|
||||||
) : (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1 ? (
|
? `Are you sure you want to move the market by ${displayedDifference}?`
|
||||||
<AlertBox
|
: undefined
|
||||||
title="Whoa, there!"
|
|
||||||
text={`Are you sure you want to move the market by ${displayedDifference}?`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={hidden ? 'hidden' : ''}>
|
<Col className={hidden ? 'hidden' : ''}>
|
||||||
|
@ -325,8 +310,6 @@ function BuyPanel(props: {
|
||||||
showSliderOnMobile
|
showSliderOnMobile
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{warning}
|
|
||||||
|
|
||||||
<Col className="mt-3 w-full gap-3">
|
<Col className="mt-3 w-full gap-3">
|
||||||
<Row className="items-center justify-between text-sm">
|
<Row className="items-center justify-between text-sm">
|
||||||
<div className="text-gray-500">
|
<div className="text-gray-500">
|
||||||
|
@ -367,20 +350,20 @@ function BuyPanel(props: {
|
||||||
<Spacer h={8} />
|
<Spacer h={8} />
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<button
|
<WarningConfirmationButton
|
||||||
className={clsx(
|
warning={warning}
|
||||||
|
onSubmit={submitBet}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
disabled={!!betDisabled}
|
||||||
|
openModalButtonClass={clsx(
|
||||||
'btn mb-2 flex-1',
|
'btn mb-2 flex-1',
|
||||||
betDisabled
|
betDisabled
|
||||||
? 'btn-disabled'
|
? 'btn-disabled'
|
||||||
: outcome === 'YES'
|
: outcome === 'YES'
|
||||||
? 'btn-primary'
|
? 'btn-primary'
|
||||||
: 'border-none bg-red-400 hover:bg-red-500',
|
: 'border-none bg-red-400 hover:bg-red-500'
|
||||||
isSubmitting ? 'loading' : ''
|
|
||||||
)}
|
)}
|
||||||
onClick={betDisabled ? undefined : submitBet}
|
/>
|
||||||
>
|
|
||||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{wasSubmitted && <div className="mt-4">Trade submitted!</div>}
|
{wasSubmitted && <div className="mt-4">Trade submitted!</div>}
|
||||||
|
@ -750,9 +733,7 @@ function QuickOrLimitBet(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="align-center mb-4 justify-between">
|
<Row className="align-center mb-4 justify-between">
|
||||||
<div className="mr-2 -ml-2 shrink-0 text-3xl sm:-ml-0 sm:text-4xl">
|
<div className="mr-2 -ml-2 shrink-0 text-3xl sm:-ml-0">Predict</div>
|
||||||
Predict
|
|
||||||
</div>
|
|
||||||
{!hideToggle && (
|
{!hideToggle && (
|
||||||
<Row className="mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2">
|
<Row className="mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2">
|
||||||
<PillButton
|
<PillButton
|
||||||
|
|
|
@ -4,7 +4,6 @@ import clsx from 'clsx'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
|
||||||
import { MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments'
|
import { MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments'
|
||||||
import { Avatar } from './avatar'
|
import { Avatar } from './avatar'
|
||||||
import { TextEditor, useTextEditor } from './editor'
|
import { TextEditor, useTextEditor } from './editor'
|
||||||
|
@ -80,7 +79,6 @@ export function CommentInputTextArea(props: {
|
||||||
upload: Parameters<typeof TextEditor>[0]['upload']
|
upload: Parameters<typeof TextEditor>[0]['upload']
|
||||||
submitComment: (id?: string) => void
|
submitComment: (id?: string) => void
|
||||||
isSubmitting: boolean
|
isSubmitting: boolean
|
||||||
submitOnEnter?: boolean
|
|
||||||
presetId?: string
|
presetId?: string
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
|
@ -90,11 +88,8 @@ export function CommentInputTextArea(props: {
|
||||||
submitComment,
|
submitComment,
|
||||||
presetId,
|
presetId,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
submitOnEnter,
|
|
||||||
replyToUser,
|
replyToUser,
|
||||||
} = props
|
} = props
|
||||||
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
editor?.setEditable(!isSubmitting)
|
editor?.setEditable(!isSubmitting)
|
||||||
}, [isSubmitting, editor])
|
}, [isSubmitting, editor])
|
||||||
|
@ -108,15 +103,14 @@ export function CommentInputTextArea(props: {
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// submit on Enter key
|
// Submit on ctrl+enter or mod+enter key
|
||||||
editor.setOptions({
|
editor.setOptions({
|
||||||
editorProps: {
|
editorProps: {
|
||||||
handleKeyDown: (view, event) => {
|
handleKeyDown: (view, event) => {
|
||||||
if (
|
if (
|
||||||
submitOnEnter &&
|
|
||||||
event.key === 'Enter' &&
|
event.key === 'Enter' &&
|
||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
(!isMobile || event.ctrlKey || event.metaKey) &&
|
(event.ctrlKey || event.metaKey) &&
|
||||||
// mention list is closed
|
// mention list is closed
|
||||||
!(view.state as any).mention$.active
|
!(view.state as any).mention$.active
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -47,13 +47,13 @@ export function ConfirmationButton(props: {
|
||||||
{children}
|
{children}
|
||||||
<Row className="gap-4">
|
<Row className="gap-4">
|
||||||
<div
|
<div
|
||||||
className={clsx('btn normal-case', cancelBtn?.className)}
|
className={clsx('btn', cancelBtn?.className)}
|
||||||
onClick={() => updateOpen(false)}
|
onClick={() => updateOpen(false)}
|
||||||
>
|
>
|
||||||
{cancelBtn?.label ?? 'Cancel'}
|
{cancelBtn?.label ?? 'Cancel'}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={clsx('btn normal-case', submitBtn?.className)}
|
className={clsx('btn', submitBtn?.className)}
|
||||||
onClick={
|
onClick={
|
||||||
onSubmitWithSuccess
|
onSubmitWithSuccess
|
||||||
? () =>
|
? () =>
|
||||||
|
@ -69,7 +69,7 @@ export function ConfirmationButton(props: {
|
||||||
</Col>
|
</Col>
|
||||||
</Modal>
|
</Modal>
|
||||||
<div
|
<div
|
||||||
className={clsx('btn normal-case', openModalBtn.className)}
|
className={clsx('btn', openModalBtn.className)}
|
||||||
onClick={() => updateOpen(true)}
|
onClick={() => updateOpen(true)}
|
||||||
>
|
>
|
||||||
{openModalBtn.icon}
|
{openModalBtn.icon}
|
||||||
|
|
|
@ -13,7 +13,6 @@ import { Tabs } from '../layout/tabs'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
||||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
import { useBets } from 'web/hooks/use-bets'
|
|
||||||
import { useComments } from 'web/hooks/use-comments'
|
import { useComments } from 'web/hooks/use-comments'
|
||||||
import { useLiquidity } from 'web/hooks/use-liquidity'
|
import { useLiquidity } from 'web/hooks/use-liquidity'
|
||||||
import { BetSignUpPrompt } from '../sign-up-prompt'
|
import { BetSignUpPrompt } from '../sign-up-prompt'
|
||||||
|
@ -27,24 +26,23 @@ export function ContractTabs(props: {
|
||||||
comments: ContractComment[]
|
comments: ContractComment[]
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
}) {
|
}) {
|
||||||
const { contract, user, tips } = props
|
const { contract, user, bets, tips } = props
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
|
|
||||||
const bets = useBets(contract.id) ?? props.bets
|
const lps = useLiquidity(contract.id)
|
||||||
const lps = useLiquidity(contract.id) ?? []
|
|
||||||
|
|
||||||
const userBets =
|
const userBets =
|
||||||
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
|
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
|
||||||
const visibleBets = bets.filter(
|
const visibleBets = bets.filter(
|
||||||
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
|
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
|
||||||
)
|
)
|
||||||
const visibleLps = lps.filter((l) => !l.isAnte && l.amount > 0)
|
const visibleLps = lps?.filter((l) => !l.isAnte && l.amount > 0)
|
||||||
|
|
||||||
// Load comments here, so the badge count will be correct
|
// Load comments here, so the badge count will be correct
|
||||||
const updatedComments = useComments(contract.id)
|
const updatedComments = useComments(contract.id)
|
||||||
const comments = updatedComments ?? props.comments
|
const comments = updatedComments ?? props.comments
|
||||||
|
|
||||||
const betActivity = (
|
const betActivity = visibleLps && (
|
||||||
<ContractBetsActivity
|
<ContractBetsActivity
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bets={visibleBets}
|
bets={visibleBets}
|
||||||
|
|
|
@ -2,74 +2,69 @@ import clsx from 'clsx'
|
||||||
import { contractPath } from 'web/lib/firebase/contracts'
|
import { contractPath } from 'web/lib/firebase/contracts'
|
||||||
import { CPMMContract } from 'common/contract'
|
import { CPMMContract } from 'common/contract'
|
||||||
import { formatPercent } from 'common/util/format'
|
import { formatPercent } from 'common/util/format'
|
||||||
import { useProbChanges } from 'web/hooks/use-prob-changes'
|
import { SiteLink } from '../site-link'
|
||||||
import { linkClass, SiteLink } from '../site-link'
|
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { useState } from 'react'
|
import { LoadingIndicator } from '../loading-indicator'
|
||||||
|
|
||||||
export function ProbChangeTable(props: { userId: string | undefined }) {
|
export function ProbChangeTable(props: {
|
||||||
const { userId } = props
|
changes:
|
||||||
|
| { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] }
|
||||||
|
| undefined
|
||||||
|
}) {
|
||||||
|
const { changes } = props
|
||||||
|
|
||||||
const changes = useProbChanges(userId ?? '')
|
if (!changes) return <LoadingIndicator />
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
|
|
||||||
if (!changes) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = expanded ? 16 : 4
|
|
||||||
|
|
||||||
const { positiveChanges, negativeChanges } = changes
|
const { positiveChanges, negativeChanges } = changes
|
||||||
const filteredPositiveChanges = positiveChanges.slice(0, count / 2)
|
|
||||||
const filteredNegativeChanges = negativeChanges.slice(0, count / 2)
|
const threshold = 0.075
|
||||||
const filteredChanges = [
|
const countOverThreshold = Math.max(
|
||||||
...filteredPositiveChanges,
|
positiveChanges.findIndex((c) => c.probChanges.day < threshold) + 1,
|
||||||
...filteredNegativeChanges,
|
negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1
|
||||||
]
|
)
|
||||||
|
const maxRows = Math.min(positiveChanges.length, negativeChanges.length)
|
||||||
|
const rows = Math.min(3, Math.min(maxRows, countOverThreshold))
|
||||||
|
|
||||||
|
const filteredPositiveChanges = positiveChanges.slice(0, rows)
|
||||||
|
const filteredNegativeChanges = negativeChanges.slice(0, rows)
|
||||||
|
|
||||||
|
if (rows === 0) return <div className="px-4 text-gray-500">None</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col>
|
<Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0">
|
||||||
<Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0">
|
<Col className="flex-1 divide-y">
|
||||||
<Col className="flex-1 divide-y">
|
{filteredPositiveChanges.map((contract) => (
|
||||||
{filteredChanges.slice(0, count / 2).map((contract) => (
|
<Row className="items-center hover:bg-gray-100">
|
||||||
<Row className="items-center hover:bg-gray-100">
|
<ProbChange
|
||||||
<ProbChange
|
className="p-4 text-right text-xl"
|
||||||
className="p-4 text-right text-xl"
|
contract={contract}
|
||||||
contract={contract}
|
/>
|
||||||
/>
|
<SiteLink
|
||||||
<SiteLink
|
className="p-4 pl-2 font-semibold text-indigo-700"
|
||||||
className="p-4 pl-2 font-semibold text-indigo-700"
|
href={contractPath(contract)}
|
||||||
href={contractPath(contract)}
|
>
|
||||||
>
|
<span className="line-clamp-2">{contract.question}</span>
|
||||||
<span className="line-clamp-2">{contract.question}</span>
|
</SiteLink>
|
||||||
</SiteLink>
|
</Row>
|
||||||
</Row>
|
))}
|
||||||
))}
|
</Col>
|
||||||
</Col>
|
<Col className="flex-1 divide-y">
|
||||||
<Col className="flex-1 divide-y">
|
{filteredNegativeChanges.map((contract) => (
|
||||||
{filteredChanges.slice(count / 2).map((contract) => (
|
<Row className="items-center hover:bg-gray-100">
|
||||||
<Row className="items-center hover:bg-gray-100">
|
<ProbChange
|
||||||
<ProbChange
|
className="p-4 text-right text-xl"
|
||||||
className="p-4 text-right text-xl"
|
contract={contract}
|
||||||
contract={contract}
|
/>
|
||||||
/>
|
<SiteLink
|
||||||
<SiteLink
|
className="p-4 pl-2 font-semibold text-indigo-700"
|
||||||
className="p-4 pl-2 font-semibold text-indigo-700"
|
href={contractPath(contract)}
|
||||||
href={contractPath(contract)}
|
>
|
||||||
>
|
<span className="line-clamp-2">{contract.question}</span>
|
||||||
<span className="line-clamp-2">{contract.question}</span>
|
</SiteLink>
|
||||||
</SiteLink>
|
</Row>
|
||||||
</Row>
|
))}
|
||||||
))}
|
|
||||||
</Col>
|
|
||||||
</Col>
|
</Col>
|
||||||
<div
|
|
||||||
className={clsx(linkClass, 'cursor-pointer self-end')}
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
>
|
|
||||||
{expanded ? 'Show less' : 'Show more'}
|
|
||||||
</div>
|
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { EyeIcon } from '@heroicons/react/outline'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export const FollowMarketModal = (props: {
|
export const WatchMarketModal = (props: {
|
||||||
open: boolean
|
open: boolean
|
||||||
setOpen: (b: boolean) => void
|
setOpen: (b: boolean) => void
|
||||||
title?: string
|
title?: string
|
||||||
|
@ -18,20 +18,21 @@ export const FollowMarketModal = (props: {
|
||||||
<Col className={'gap-2'}>
|
<Col className={'gap-2'}>
|
||||||
<span className={'text-indigo-700'}>• What is watching?</span>
|
<span className={'text-indigo-700'}>• What is watching?</span>
|
||||||
<span className={'ml-2'}>
|
<span className={'ml-2'}>
|
||||||
You can receive notifications on questions you're interested in by
|
You'll receive notifications on markets by betting, commenting, or
|
||||||
clicking the
|
clicking the
|
||||||
<EyeIcon
|
<EyeIcon
|
||||||
className={clsx('ml-1 inline h-6 w-6 align-top')}
|
className={clsx('ml-1 inline h-6 w-6 align-top')}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
️ button on a question.
|
️ button on them.
|
||||||
</span>
|
</span>
|
||||||
<span className={'text-indigo-700'}>
|
<span className={'text-indigo-700'}>
|
||||||
• What types of notifications will I receive?
|
• What types of notifications will I receive?
|
||||||
</span>
|
</span>
|
||||||
<span className={'ml-2'}>
|
<span className={'ml-2'}>
|
||||||
You'll receive in-app notifications for new comments, answers, and
|
You'll receive notifications for new comments, answers, and updates
|
||||||
updates to the question.
|
to the question. See the notifications settings pages to customize
|
||||||
|
which types of notifications you receive on watched markets.
|
||||||
</span>
|
</span>
|
||||||
</Col>
|
</Col>
|
||||||
</Col>
|
</Col>
|
|
@ -2,6 +2,7 @@ import CharacterCount from '@tiptap/extension-character-count'
|
||||||
import Placeholder from '@tiptap/extension-placeholder'
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import {
|
import {
|
||||||
useEditor,
|
useEditor,
|
||||||
|
BubbleMenu,
|
||||||
EditorContent,
|
EditorContent,
|
||||||
JSONContent,
|
JSONContent,
|
||||||
Content,
|
Content,
|
||||||
|
@ -18,20 +19,25 @@ import { uploadImage } from 'web/lib/firebase/storage'
|
||||||
import { useMutation } from 'react-query'
|
import { useMutation } from 'react-query'
|
||||||
import { FileUploadButton } from './file-upload-button'
|
import { FileUploadButton } from './file-upload-button'
|
||||||
import { linkClass } from './site-link'
|
import { linkClass } from './site-link'
|
||||||
import { useUsers } from 'web/hooks/use-users'
|
|
||||||
import { mentionSuggestion } from './editor/mention-suggestion'
|
import { mentionSuggestion } from './editor/mention-suggestion'
|
||||||
import { DisplayMention } from './editor/mention'
|
import { DisplayMention } from './editor/mention'
|
||||||
import Iframe from 'common/util/tiptap-iframe'
|
import Iframe from 'common/util/tiptap-iframe'
|
||||||
import TiptapTweet from './editor/tiptap-tweet'
|
import TiptapTweet from './editor/tiptap-tweet'
|
||||||
import { EmbedModal } from './editor/embed-modal'
|
import { EmbedModal } from './editor/embed-modal'
|
||||||
import {
|
import {
|
||||||
|
CheckIcon,
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
PhotographIcon,
|
PhotographIcon,
|
||||||
PresentationChartLineIcon,
|
PresentationChartLineIcon,
|
||||||
|
TrashIcon,
|
||||||
} from '@heroicons/react/solid'
|
} from '@heroicons/react/solid'
|
||||||
import { MarketModal } from './editor/market-modal'
|
import { MarketModal } from './editor/market-modal'
|
||||||
import { insertContent } from './editor/utils'
|
import { insertContent } from './editor/utils'
|
||||||
import { Tooltip } from './tooltip'
|
import { Tooltip } from './tooltip'
|
||||||
|
import BoldIcon from 'web/lib/icons/bold-icon'
|
||||||
|
import ItalicIcon from 'web/lib/icons/italic-icon'
|
||||||
|
import LinkIcon from 'web/lib/icons/link-icon'
|
||||||
|
import { getUrl } from 'common/util/parse'
|
||||||
|
|
||||||
const DisplayImage = Image.configure({
|
const DisplayImage = Image.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
|
@ -68,8 +74,6 @@ export function useTextEditor(props: {
|
||||||
}) {
|
}) {
|
||||||
const { placeholder, max, defaultValue = '', disabled, simple } = props
|
const { placeholder, max, defaultValue = '', disabled, simple } = props
|
||||||
|
|
||||||
const users = useUsers()
|
|
||||||
|
|
||||||
const editorClass = clsx(
|
const editorClass = clsx(
|
||||||
proseClass,
|
proseClass,
|
||||||
!simple && 'min-h-[6em]',
|
!simple && 'min-h-[6em]',
|
||||||
|
@ -78,32 +82,27 @@ export function useTextEditor(props: {
|
||||||
'[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, emebeds
|
'[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, emebeds
|
||||||
)
|
)
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor({
|
||||||
{
|
editorProps: { attributes: { class: editorClass } },
|
||||||
editorProps: { attributes: { class: editorClass } },
|
extensions: [
|
||||||
extensions: [
|
StarterKit.configure({
|
||||||
StarterKit.configure({
|
heading: simple ? false : { levels: [1, 2, 3] },
|
||||||
heading: simple ? false : { levels: [1, 2, 3] },
|
horizontalRule: simple ? false : {},
|
||||||
horizontalRule: simple ? false : {},
|
}),
|
||||||
}),
|
Placeholder.configure({
|
||||||
Placeholder.configure({
|
placeholder,
|
||||||
placeholder,
|
emptyEditorClass:
|
||||||
emptyEditorClass:
|
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
|
||||||
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
|
}),
|
||||||
}),
|
CharacterCount.configure({ limit: max }),
|
||||||
CharacterCount.configure({ limit: max }),
|
simple ? DisplayImage : Image,
|
||||||
simple ? DisplayImage : Image,
|
DisplayLink,
|
||||||
DisplayLink,
|
DisplayMention.configure({ suggestion: mentionSuggestion }),
|
||||||
DisplayMention.configure({
|
Iframe,
|
||||||
suggestion: mentionSuggestion(users),
|
TiptapTweet,
|
||||||
}),
|
],
|
||||||
Iframe,
|
content: defaultValue,
|
||||||
TiptapTweet,
|
})
|
||||||
],
|
|
||||||
content: defaultValue,
|
|
||||||
},
|
|
||||||
[!users.length] // passed as useEffect dependency. (re-render editor when users load, to update mention menu)
|
|
||||||
)
|
|
||||||
|
|
||||||
const upload = useUploadMutation(editor)
|
const upload = useUploadMutation(editor)
|
||||||
|
|
||||||
|
@ -149,6 +148,66 @@ function isValidIframe(text: string) {
|
||||||
return /^<iframe.*<\/iframe>$/.test(text)
|
return /^<iframe.*<\/iframe>$/.test(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FloatingMenu(props: { editor: Editor | null }) {
|
||||||
|
const { editor } = props
|
||||||
|
|
||||||
|
const [url, setUrl] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (!editor) return null
|
||||||
|
|
||||||
|
// current selection
|
||||||
|
const isBold = editor.isActive('bold')
|
||||||
|
const isItalic = editor.isActive('italic')
|
||||||
|
const isLink = editor.isActive('link')
|
||||||
|
|
||||||
|
const setLink = () => {
|
||||||
|
const href = url && getUrl(url)
|
||||||
|
if (href) {
|
||||||
|
editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsetLink = () => editor.chain().focus().unsetLink().run()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
className="flex gap-2 rounded-sm bg-slate-700 p-1 text-white"
|
||||||
|
>
|
||||||
|
{url === null ? (
|
||||||
|
<>
|
||||||
|
<button onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||||
|
<BoldIcon className={clsx('h-5', isBold && 'text-indigo-200')} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||||
|
<ItalicIcon
|
||||||
|
className={clsx('h-5', isItalic && 'text-indigo-200')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => (isLink ? unsetLink() : setUrl(''))}>
|
||||||
|
<LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-5 border-0 bg-inherit text-sm !shadow-none !ring-0"
|
||||||
|
placeholder="Type or paste a link"
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button onClick={() => (setLink(), setUrl(null))}>
|
||||||
|
<CheckIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => (unsetLink(), setUrl(null))}>
|
||||||
|
<TrashIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</BubbleMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function TextEditor(props: {
|
export function TextEditor(props: {
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
upload: ReturnType<typeof useUploadMutation>
|
upload: ReturnType<typeof useUploadMutation>
|
||||||
|
@ -163,6 +222,7 @@ export function TextEditor(props: {
|
||||||
{/* hide placeholder when focused */}
|
{/* hide placeholder when focused */}
|
||||||
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
|
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
|
||||||
<div className="rounded-lg border border-gray-300 bg-white shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
|
<div className="rounded-lg border border-gray-300 bg-white shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
|
||||||
|
<FloatingMenu editor={editor} />
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
{/* Toolbar, with buttons for images and embeds */}
|
{/* Toolbar, with buttons for images and embeds */}
|
||||||
<div className="flex h-9 items-center gap-5 pl-4 pr-1">
|
<div className="flex h-9 items-center gap-5 pl-4 pr-1">
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import type { MentionOptions } from '@tiptap/extension-mention'
|
import type { MentionOptions } from '@tiptap/extension-mention'
|
||||||
import { ReactRenderer } from '@tiptap/react'
|
import { ReactRenderer } from '@tiptap/react'
|
||||||
import { User } from 'common/user'
|
|
||||||
import { searchInAny } from 'common/util/parse'
|
import { searchInAny } from 'common/util/parse'
|
||||||
import { orderBy } from 'lodash'
|
import { orderBy } from 'lodash'
|
||||||
import tippy from 'tippy.js'
|
import tippy from 'tippy.js'
|
||||||
|
import { getCachedUsers } from 'web/hooks/use-users'
|
||||||
import { MentionList } from './mention-list'
|
import { MentionList } from './mention-list'
|
||||||
|
|
||||||
type Suggestion = MentionOptions['suggestion']
|
type Suggestion = MentionOptions['suggestion']
|
||||||
|
@ -12,10 +12,12 @@ const beginsWith = (text: string, query: string) =>
|
||||||
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
|
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
|
||||||
|
|
||||||
// copied from https://tiptap.dev/api/nodes/mention#usage
|
// copied from https://tiptap.dev/api/nodes/mention#usage
|
||||||
export const mentionSuggestion = (users: User[]): Suggestion => ({
|
export const mentionSuggestion: Suggestion = {
|
||||||
items: ({ query }) =>
|
items: async ({ query }) =>
|
||||||
orderBy(
|
orderBy(
|
||||||
users.filter((u) => searchInAny(query, u.username, u.name)),
|
(await getCachedUsers()).filter((u) =>
|
||||||
|
searchInAny(query, u.username, u.name)
|
||||||
|
),
|
||||||
[
|
[
|
||||||
(u) => [u.name, u.username].some((s) => beginsWith(s, query)),
|
(u) => [u.name, u.username].some((s) => beginsWith(s, query)),
|
||||||
'followerCountCached',
|
'followerCountCached',
|
||||||
|
@ -38,7 +40,7 @@ export const mentionSuggestion = (users: User[]): Suggestion => ({
|
||||||
popup = tippy('body', {
|
popup = tippy('body', {
|
||||||
getReferenceClientRect: props.clientRect as any,
|
getReferenceClientRect: props.clientRect as any,
|
||||||
appendTo: () => document.body,
|
appendTo: () => document.body,
|
||||||
content: component.element,
|
content: component?.element,
|
||||||
showOnCreate: true,
|
showOnCreate: true,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
trigger: 'manual',
|
trigger: 'manual',
|
||||||
|
@ -46,27 +48,27 @@ export const mentionSuggestion = (users: User[]): Suggestion => ({
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onUpdate(props) {
|
onUpdate(props) {
|
||||||
component.updateProps(props)
|
component?.updateProps(props)
|
||||||
|
|
||||||
if (!props.clientRect) {
|
if (!props.clientRect) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
popup[0].setProps({
|
popup?.[0].setProps({
|
||||||
getReferenceClientRect: props.clientRect as any,
|
getReferenceClientRect: props.clientRect as any,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onKeyDown(props) {
|
onKeyDown(props) {
|
||||||
if (props.event.key === 'Escape') {
|
if (props.event.key === 'Escape') {
|
||||||
popup[0].hide()
|
popup?.[0].hide()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return (component.ref as any)?.onKeyDown(props)
|
return (component?.ref as any)?.onKeyDown(props)
|
||||||
},
|
},
|
||||||
onExit() {
|
onExit() {
|
||||||
popup[0].destroy()
|
popup?.[0].destroy()
|
||||||
component.destroy()
|
component?.destroy()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
|
|
@ -14,13 +14,10 @@ import { OutcomeLabel } from 'web/components/outcome-label'
|
||||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||||
import { createCommentOnContract } from 'web/lib/firebase/comments'
|
import { createCommentOnContract } from 'web/lib/firebase/comments'
|
||||||
import { BetStatusText } from 'web/components/feed/feed-bets'
|
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { getProbability } from 'common/calculate'
|
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { Tipper } from '../tipper'
|
import { Tipper } from '../tipper'
|
||||||
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
||||||
|
|
||||||
import { Content } from '../editor'
|
import { Content } from '../editor'
|
||||||
import { Editor } from '@tiptap/react'
|
import { Editor } from '@tiptap/react'
|
||||||
import { UserLink } from 'web/components/user-link'
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
@ -302,74 +299,14 @@ export function ContractCommentInput(props: {
|
||||||
const { id } = mostRecentCommentableBet || { id: undefined }
|
const { id } = mostRecentCommentableBet || { id: undefined }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col>
|
<CommentInput
|
||||||
<CommentBetArea
|
replyToUser={props.replyToUser}
|
||||||
betsByCurrentUser={props.betsByCurrentUser}
|
parentAnswerOutcome={props.parentAnswerOutcome}
|
||||||
contract={props.contract}
|
parentCommentId={props.parentCommentId}
|
||||||
commentsByCurrentUser={props.commentsByCurrentUser}
|
onSubmitComment={onSubmitComment}
|
||||||
parentAnswerOutcome={props.parentAnswerOutcome}
|
className={props.className}
|
||||||
user={useUser()}
|
presetId={id}
|
||||||
className={props.className}
|
/>
|
||||||
mostRecentCommentableBet={mostRecentCommentableBet}
|
|
||||||
/>
|
|
||||||
<CommentInput
|
|
||||||
replyToUser={props.replyToUser}
|
|
||||||
parentAnswerOutcome={props.parentAnswerOutcome}
|
|
||||||
parentCommentId={props.parentCommentId}
|
|
||||||
onSubmitComment={onSubmitComment}
|
|
||||||
className={props.className}
|
|
||||||
presetId={id}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommentBetArea(props: {
|
|
||||||
betsByCurrentUser: Bet[]
|
|
||||||
contract: Contract
|
|
||||||
commentsByCurrentUser: ContractComment[]
|
|
||||||
parentAnswerOutcome?: string
|
|
||||||
user?: User | null
|
|
||||||
className?: string
|
|
||||||
mostRecentCommentableBet?: Bet
|
|
||||||
}) {
|
|
||||||
const { betsByCurrentUser, contract, user, mostRecentCommentableBet } = props
|
|
||||||
|
|
||||||
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
|
|
||||||
contract,
|
|
||||||
Date.now(),
|
|
||||||
betsByCurrentUser
|
|
||||||
)
|
|
||||||
|
|
||||||
const isNumeric = contract.outcomeType === 'NUMERIC'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
|
|
||||||
<div className="mb-1 text-gray-500">
|
|
||||||
{mostRecentCommentableBet && (
|
|
||||||
<BetStatusText
|
|
||||||
contract={contract}
|
|
||||||
bet={mostRecentCommentableBet}
|
|
||||||
isSelf={true}
|
|
||||||
hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
|
|
||||||
<>
|
|
||||||
{"You're"}
|
|
||||||
<CommentStatus
|
|
||||||
outcome={outcome}
|
|
||||||
contract={contract}
|
|
||||||
prob={
|
|
||||||
contract.outcomeType === 'BINARY'
|
|
||||||
? getProbability(contract)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { User } from 'common/user'
|
||||||
import { useContractFollows } from 'web/hooks/use-follows'
|
import { useContractFollows } from 'web/hooks/use-follows'
|
||||||
import { firebaseLogin, updateUser } from 'web/lib/firebase/users'
|
import { firebaseLogin, updateUser } from 'web/lib/firebase/users'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
|
import { WatchMarketModal } from 'web/components/contract/watch-market-modal'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ export const FollowMarketButton = (props: {
|
||||||
Watch
|
Watch
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
<FollowMarketModal
|
<WatchMarketModal
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
title={`You ${
|
title={`You ${
|
||||||
|
|
|
@ -22,7 +22,7 @@ export function GroupAboutPost(props: {
|
||||||
const post = usePost(group.aboutPostId) ?? props.post
|
const post = usePost(group.aboutPostId) ?? props.post
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md bg-white p-4">
|
<div className="rounded-md bg-white p-4 ">
|
||||||
{isEditable ? (
|
{isEditable ? (
|
||||||
<RichEditGroupAboutPost group={group} post={post} />
|
<RichEditGroupAboutPost group={group} post={post} />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Image from 'next/future/image'
|
||||||
import { SparklesIcon } from '@heroicons/react/solid'
|
import { SparklesIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
|
@ -18,7 +19,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Col className="mb-6 rounded-xl sm:m-12 sm:mt-0">
|
<Col className="mb-6 rounded-xl sm:m-12 sm:mt-0">
|
||||||
<img
|
<Image
|
||||||
height={250}
|
height={250}
|
||||||
width={250}
|
width={250}
|
||||||
className="self-center"
|
className="self-center"
|
||||||
|
|
|
@ -8,9 +8,10 @@ export function Modal(props: {
|
||||||
open: boolean
|
open: boolean
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
position?: 'center' | 'top' | 'bottom'
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { children, open, setOpen, size = 'md', className } = props
|
const { children, position, open, setOpen, size = 'md', className } = props
|
||||||
|
|
||||||
const sizeClass = {
|
const sizeClass = {
|
||||||
sm: 'max-w-sm',
|
sm: 'max-w-sm',
|
||||||
|
@ -19,6 +20,12 @@ export function Modal(props: {
|
||||||
xl: 'max-w-5xl',
|
xl: 'max-w-5xl',
|
||||||
}[size]
|
}[size]
|
||||||
|
|
||||||
|
const positionClass = {
|
||||||
|
center: 'items-center',
|
||||||
|
top: 'items-start',
|
||||||
|
bottom: 'items-end',
|
||||||
|
}[position ?? 'bottom']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition.Root show={open} as={Fragment}>
|
<Transition.Root show={open} as={Fragment}>
|
||||||
<Dialog
|
<Dialog
|
||||||
|
@ -26,7 +33,12 @@ export function Modal(props: {
|
||||||
className="fixed inset-0 z-50 overflow-y-auto"
|
className="fixed inset-0 z-50 overflow-y-auto"
|
||||||
onClose={setOpen}
|
onClose={setOpen}
|
||||||
>
|
>
|
||||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:p-0">
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex min-h-screen justify-center px-4 pt-4 pb-20 text-center sm:p-0',
|
||||||
|
positionClass
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
|
|
26
web/components/market-intro-panel.tsx
Normal file
26
web/components/market-intro-panel.tsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import Image from 'next/future/image'
|
||||||
|
|
||||||
|
import { Col } from './layout/col'
|
||||||
|
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||||
|
|
||||||
|
export function MarketIntroPanel() {
|
||||||
|
return (
|
||||||
|
<Col>
|
||||||
|
<div className="text-xl">Play-money predictions</div>
|
||||||
|
|
||||||
|
<Image
|
||||||
|
height={150}
|
||||||
|
width={150}
|
||||||
|
className="self-center"
|
||||||
|
src="/flappy-logo.gif"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4 text-sm">
|
||||||
|
Manifold Markets is a play-money prediction market platform where you
|
||||||
|
can forecast anything.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BetSignUpPrompt />
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
320
web/components/notification-settings.tsx
Normal file
320
web/components/notification-settings.tsx
Normal file
|
@ -0,0 +1,320 @@
|
||||||
|
import { usePrivateUser } from 'web/hooks/use-user'
|
||||||
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import {
|
||||||
|
notification_subscription_types,
|
||||||
|
notification_destination_types,
|
||||||
|
} from 'common/user'
|
||||||
|
import { updatePrivateUser } from 'web/lib/firebase/users'
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import {
|
||||||
|
CashIcon,
|
||||||
|
ChatIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
CurrencyDollarIcon,
|
||||||
|
InboxInIcon,
|
||||||
|
InformationCircleIcon,
|
||||||
|
LightBulbIcon,
|
||||||
|
TrendingUpIcon,
|
||||||
|
UserIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from '@heroicons/react/outline'
|
||||||
|
import { WatchMarketModal } from 'web/components/contract/watch-market-modal'
|
||||||
|
import { filterDefined } from 'common/util/array'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { SwitchSetting } from 'web/components/switch-setting'
|
||||||
|
|
||||||
|
export function NotificationSettings(props: {
|
||||||
|
navigateToSection: string | undefined
|
||||||
|
}) {
|
||||||
|
const { navigateToSection } = props
|
||||||
|
const privateUser = usePrivateUser()
|
||||||
|
const [showWatchModal, setShowWatchModal] = useState(false)
|
||||||
|
|
||||||
|
if (!privateUser || !privateUser.notificationSubscriptionTypes) {
|
||||||
|
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailsEnabled: Array<keyof notification_subscription_types> = [
|
||||||
|
'all_comments_on_watched_markets',
|
||||||
|
'all_replies_to_my_comments_on_watched_markets',
|
||||||
|
'all_comments_on_contracts_with_shares_in_on_watched_markets',
|
||||||
|
|
||||||
|
'all_answers_on_watched_markets',
|
||||||
|
'all_replies_to_my_answers_on_watched_markets',
|
||||||
|
'all_answers_on_contracts_with_shares_in_on_watched_markets',
|
||||||
|
|
||||||
|
'your_contract_closed',
|
||||||
|
'all_comments_on_my_markets',
|
||||||
|
'all_answers_on_my_markets',
|
||||||
|
|
||||||
|
'resolutions_on_watched_markets_with_shares_in',
|
||||||
|
'resolutions_on_watched_markets',
|
||||||
|
|
||||||
|
'trending_markets',
|
||||||
|
'onboarding_flow',
|
||||||
|
'thank_you_for_purchases',
|
||||||
|
|
||||||
|
'tagged_user', // missing tagged on contract description email
|
||||||
|
'contract_from_followed_user',
|
||||||
|
// TODO: add these
|
||||||
|
// 'referral_bonuses',
|
||||||
|
// 'unique_bettors_on_your_contract',
|
||||||
|
// 'on_new_follow',
|
||||||
|
// 'profit_loss_updates',
|
||||||
|
// 'tips_on_your_markets',
|
||||||
|
// 'tips_on_your_comments',
|
||||||
|
// maybe the following?
|
||||||
|
// 'probability_updates_on_watched_markets',
|
||||||
|
// 'limit_order_fills',
|
||||||
|
]
|
||||||
|
const browserDisabled: Array<keyof notification_subscription_types> = [
|
||||||
|
'trending_markets',
|
||||||
|
'profit_loss_updates',
|
||||||
|
'onboarding_flow',
|
||||||
|
'thank_you_for_purchases',
|
||||||
|
]
|
||||||
|
|
||||||
|
type sectionData = {
|
||||||
|
label: string
|
||||||
|
subscriptionTypeToDescription: {
|
||||||
|
[key in keyof Partial<notification_subscription_types>]: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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`,
|
||||||
|
// 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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const bonuses: sectionData = {
|
||||||
|
label: 'Bonuses',
|
||||||
|
subscriptionTypeToDescription: {
|
||||||
|
betting_streaks: 'Betting streak bonuses',
|
||||||
|
referral_bonuses: 'Referral bonuses from referring users',
|
||||||
|
unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationSettingLine = (
|
||||||
|
description: string,
|
||||||
|
key: keyof notification_subscription_types,
|
||||||
|
value: notification_destination_types[]
|
||||||
|
) => {
|
||||||
|
const previousInAppValue = value.includes('browser')
|
||||||
|
const previousEmailValue = value.includes('email')
|
||||||
|
const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue)
|
||||||
|
const [emailEnabled, setEmailEnabled] = useState(previousEmailValue)
|
||||||
|
const loading = 'Changing Notifications Settings'
|
||||||
|
const success = 'Changed Notification Settings!'
|
||||||
|
const highlight = navigateToSection === key
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
inAppEnabled !== previousInAppValue ||
|
||||||
|
emailEnabled !== previousEmailValue
|
||||||
|
) {
|
||||||
|
toast.promise(
|
||||||
|
updatePrivateUser(privateUser.id, {
|
||||||
|
notificationSubscriptionTypes: {
|
||||||
|
...privateUser.notificationSubscriptionTypes,
|
||||||
|
[key]: filterDefined([
|
||||||
|
inAppEnabled ? 'browser' : undefined,
|
||||||
|
emailEnabled ? 'email' : undefined,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
success,
|
||||||
|
loading,
|
||||||
|
error: 'Error changing notification settings. Try again?',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
inAppEnabled,
|
||||||
|
emailEnabled,
|
||||||
|
previousInAppValue,
|
||||||
|
previousEmailValue,
|
||||||
|
key,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
className={clsx(
|
||||||
|
'my-1 gap-1 text-gray-300',
|
||||||
|
highlight ? 'rounded-md bg-indigo-100 p-1' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Col className="ml-3 gap-2 text-sm">
|
||||||
|
<Row className="gap-2 font-medium text-gray-700">
|
||||||
|
<span>{description}</span>
|
||||||
|
</Row>
|
||||||
|
<Row className={'gap-4'}>
|
||||||
|
{!browserDisabled.includes(key) && (
|
||||||
|
<SwitchSetting
|
||||||
|
checked={inAppEnabled}
|
||||||
|
onChange={setInAppEnabled}
|
||||||
|
label={'Web'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{emailsEnabled.includes(key) && (
|
||||||
|
<SwitchSetting
|
||||||
|
checked={emailEnabled}
|
||||||
|
onChange={setEmailEnabled}
|
||||||
|
label={'Email'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUsersSavedPreference = (
|
||||||
|
key: keyof notification_subscription_types
|
||||||
|
) => {
|
||||||
|
return privateUser.notificationSubscriptionTypes[key] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
const Section = (icon: ReactNode, data: sectionData) => {
|
||||||
|
const { label, subscriptionTypeToDescription } = data
|
||||||
|
const expand =
|
||||||
|
navigateToSection &&
|
||||||
|
Object.keys(subscriptionTypeToDescription).includes(navigateToSection)
|
||||||
|
const [expanded, setExpanded] = useState(expand)
|
||||||
|
|
||||||
|
// Not working as the default value for expanded, so using a useEffect
|
||||||
|
useEffect(() => {
|
||||||
|
if (expand) setExpanded(true)
|
||||||
|
}, [expand])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col className={clsx('ml-2 gap-2')}>
|
||||||
|
<Row
|
||||||
|
className={'mt-1 cursor-pointer items-center gap-2 text-gray-600'}
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronUpIcon className="h-5 w-5 text-xs text-gray-500">
|
||||||
|
Hide
|
||||||
|
</ChevronUpIcon>
|
||||||
|
) : (
|
||||||
|
<ChevronDownIcon className="h-5 w-5 text-xs text-gray-500">
|
||||||
|
Show
|
||||||
|
</ChevronDownIcon>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
<Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}>
|
||||||
|
{Object.entries(subscriptionTypeToDescription).map(([key, value]) =>
|
||||||
|
NotificationSettingLine(
|
||||||
|
value,
|
||||||
|
key as keyof notification_subscription_types,
|
||||||
|
getUsersSavedPreference(
|
||||||
|
key as keyof notification_subscription_types
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'p-2'}>
|
||||||
|
<Col className={'gap-6'}>
|
||||||
|
<Row className={'gap-2 text-xl text-gray-700'}>
|
||||||
|
<span>Notifications for Watched Markets</span>
|
||||||
|
<InformationCircleIcon
|
||||||
|
className="-mb-1 h-5 w-5 cursor-pointer text-gray-500"
|
||||||
|
onClick={() => setShowWatchModal(true)}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
{Section(<ChatIcon className={'h-6 w-6'} />, comments)}
|
||||||
|
{Section(<LightBulbIcon className={'h-6 w-6'} />, answers)}
|
||||||
|
{Section(<TrendingUpIcon className={'h-6 w-6'} />, updates)}
|
||||||
|
{Section(<UserIcon className={'h-6 w-6'} />, yourMarkets)}
|
||||||
|
<Row className={'gap-2 text-xl text-gray-700'}>
|
||||||
|
<span>Balance Changes</span>
|
||||||
|
</Row>
|
||||||
|
{Section(<CurrencyDollarIcon className={'h-6 w-6'} />, bonuses)}
|
||||||
|
{Section(<CashIcon className={'h-6 w-6'} />, otherBalances)}
|
||||||
|
<Row className={'gap-2 text-xl text-gray-700'}>
|
||||||
|
<span>General</span>
|
||||||
|
</Row>
|
||||||
|
{Section(<UsersIcon className={'h-6 w-6'} />, userInteractions)}
|
||||||
|
{Section(<InboxInIcon className={'h-6 w-6'} />, generalOther)}
|
||||||
|
<WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} />
|
||||||
|
</Col>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
34
web/components/switch-setting.tsx
Normal file
34
web/components/switch-setting.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { Switch } from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export const SwitchSetting = (props: {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (checked: boolean) => void
|
||||||
|
label: string
|
||||||
|
}) => {
|
||||||
|
const { checked, onChange, label } = props
|
||||||
|
return (
|
||||||
|
<Switch.Group as="div" className="flex items-center">
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
className={clsx(
|
||||||
|
checked ? 'bg-indigo-600' : 'bg-gray-200',
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={clsx(
|
||||||
|
checked ? 'translate-x-5' : 'translate-x-0',
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
<Switch.Label as="span" className="ml-3">
|
||||||
|
<span className="text-sm font-medium text-gray-900">{label}</span>
|
||||||
|
</Switch.Label>
|
||||||
|
</Switch.Group>
|
||||||
|
)
|
||||||
|
}
|
74
web/components/warning-confirmation-button.tsx
Normal file
74
web/components/warning-confirmation-button.tsx
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Row } from './layout/row'
|
||||||
|
import { ConfirmationButton } from './confirmation-button'
|
||||||
|
import { ExclamationIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
|
export function WarningConfirmationButton(props: {
|
||||||
|
warning?: string
|
||||||
|
onSubmit: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
isSubmitting: boolean
|
||||||
|
openModalButtonClass?: string
|
||||||
|
submitButtonClassName?: string
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
onSubmit,
|
||||||
|
warning,
|
||||||
|
disabled,
|
||||||
|
isSubmitting,
|
||||||
|
openModalButtonClass,
|
||||||
|
submitButtonClassName,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
if (!warning) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
openModalButtonClass,
|
||||||
|
isSubmitting ? 'loading' : '',
|
||||||
|
disabled && 'btn-disabled'
|
||||||
|
)}
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmationButton
|
||||||
|
openModalBtn={{
|
||||||
|
className: clsx(
|
||||||
|
openModalButtonClass,
|
||||||
|
isSubmitting && 'btn-disabled loading'
|
||||||
|
),
|
||||||
|
label: 'Submit',
|
||||||
|
}}
|
||||||
|
cancelBtn={{
|
||||||
|
label: 'Cancel',
|
||||||
|
className: 'btn-warning',
|
||||||
|
}}
|
||||||
|
submitBtn={{
|
||||||
|
label: 'Submit',
|
||||||
|
className: clsx(
|
||||||
|
'border-none btn-sm btn-ghost self-center',
|
||||||
|
submitButtonClassName
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
|
<Row className="items-center text-xl">
|
||||||
|
<ExclamationIcon
|
||||||
|
className="h-16 w-16 text-yellow-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Whoa, there!
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<p>{warning}</p>
|
||||||
|
</ConfirmationButton>
|
||||||
|
)
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ import {
|
||||||
getUserBetContractsQuery,
|
getUserBetContractsQuery,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { useQueryClient } from 'react-query'
|
import { useQueryClient } from 'react-query'
|
||||||
|
import { MINUTE_MS } from 'common/util/time'
|
||||||
|
|
||||||
export const useContracts = () => {
|
export const useContracts = () => {
|
||||||
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
||||||
|
@ -96,8 +97,10 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => {
|
||||||
|
|
||||||
export const usePrefetchUserBetContracts = (userId: string) => {
|
export const usePrefetchUserBetContracts = (userId: string) => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return queryClient.prefetchQuery(['contracts', 'bets', userId], () =>
|
return queryClient.prefetchQuery(
|
||||||
getUserBetContracts(userId)
|
['contracts', 'bets', userId],
|
||||||
|
() => getUserBetContracts(userId),
|
||||||
|
{ staleTime: 5 * MINUTE_MS }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { notification_subscribe_types, PrivateUser } from 'common/user'
|
import { PrivateUser } from 'common/user'
|
||||||
import { Notification } from 'common/notification'
|
import { Notification } from 'common/notification'
|
||||||
import { getNotificationsQuery } from 'web/lib/firebase/notifications'
|
import { getNotificationsQuery } from 'web/lib/firebase/notifications'
|
||||||
import { groupBy, map, partition } from 'lodash'
|
import { groupBy, map, partition } from 'lodash'
|
||||||
|
@ -23,11 +23,8 @@ function useNotifications(privateUser: PrivateUser) {
|
||||||
if (!result.data) return undefined
|
if (!result.data) return undefined
|
||||||
const notifications = result.data as Notification[]
|
const notifications = result.data as Notification[]
|
||||||
|
|
||||||
return getAppropriateNotifications(
|
return notifications.filter((n) => !n.isSeenOnHref)
|
||||||
notifications,
|
}, [result.data])
|
||||||
privateUser.notificationPreferences
|
|
||||||
).filter((n) => !n.isSeenOnHref)
|
|
||||||
}, [privateUser.notificationPreferences, result.data])
|
|
||||||
|
|
||||||
return notifications
|
return notifications
|
||||||
}
|
}
|
||||||
|
@ -111,29 +108,3 @@ export function groupNotifications(notifications: Notification[]) {
|
||||||
})
|
})
|
||||||
return notificationGroups
|
return notificationGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
const lessPriorityReasons = [
|
|
||||||
'on_contract_with_users_comment',
|
|
||||||
'on_contract_with_users_answer',
|
|
||||||
// Notifications not currently generated for users who've sold their shares
|
|
||||||
'on_contract_with_users_shares_out',
|
|
||||||
// Not sure if users will want to see these w/ less:
|
|
||||||
// 'on_contract_with_users_shares_in',
|
|
||||||
]
|
|
||||||
|
|
||||||
function getAppropriateNotifications(
|
|
||||||
notifications: Notification[],
|
|
||||||
notificationPreferences?: notification_subscribe_types
|
|
||||||
) {
|
|
||||||
if (notificationPreferences === 'all') return notifications
|
|
||||||
if (notificationPreferences === 'less')
|
|
||||||
return notifications.filter(
|
|
||||||
(n) =>
|
|
||||||
n.reason &&
|
|
||||||
// Show all contract notifications and any that aren't in the above list:
|
|
||||||
(n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason))
|
|
||||||
)
|
|
||||||
if (notificationPreferences === 'none') return []
|
|
||||||
|
|
||||||
return notifications
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { useQueryClient } from 'react-query'
|
import { useQueryClient } from 'react-query'
|
||||||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||||
import { DAY_MS, HOUR_MS } from 'common/util/time'
|
import { DAY_MS, HOUR_MS, MINUTE_MS } from 'common/util/time'
|
||||||
import {
|
import {
|
||||||
getPortfolioHistory,
|
getPortfolioHistory,
|
||||||
getPortfolioHistoryQuery,
|
getPortfolioHistoryQuery,
|
||||||
|
@ -15,8 +15,10 @@ const getCutoff = (period: Period) => {
|
||||||
export const usePrefetchPortfolioHistory = (userId: string, period: Period) => {
|
export const usePrefetchPortfolioHistory = (userId: string, period: Period) => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const cutoff = getCutoff(period)
|
const cutoff = getCutoff(period)
|
||||||
return queryClient.prefetchQuery(['portfolio-history', userId, cutoff], () =>
|
return queryClient.prefetchQuery(
|
||||||
getPortfolioHistory(userId, cutoff)
|
['portfolio-history', userId, cutoff],
|
||||||
|
() => getPortfolioHistory(userId, cutoff),
|
||||||
|
{ staleTime: 15 * MINUTE_MS }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,7 +26,9 @@ export const usePortfolioHistory = (userId: string, period: Period) => {
|
||||||
const cutoff = getCutoff(period)
|
const cutoff = getCutoff(period)
|
||||||
const result = useFirestoreQueryData(
|
const result = useFirestoreQueryData(
|
||||||
['portfolio-history', userId, cutoff],
|
['portfolio-history', userId, cutoff],
|
||||||
getPortfolioHistoryQuery(userId, cutoff)
|
getPortfolioHistoryQuery(userId, cutoff),
|
||||||
|
{},
|
||||||
|
{ staleTime: 15 * MINUTE_MS }
|
||||||
)
|
)
|
||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,10 +7,15 @@ import {
|
||||||
getUserBetsQuery,
|
getUserBetsQuery,
|
||||||
listenForUserContractBets,
|
listenForUserContractBets,
|
||||||
} from 'web/lib/firebase/bets'
|
} from 'web/lib/firebase/bets'
|
||||||
|
import { MINUTE_MS } from 'common/util/time'
|
||||||
|
|
||||||
export const usePrefetchUserBets = (userId: string) => {
|
export const usePrefetchUserBets = (userId: string) => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return queryClient.prefetchQuery(['bets', userId], () => getUserBets(userId))
|
return queryClient.prefetchQuery(
|
||||||
|
['bets', userId],
|
||||||
|
() => getUserBets(userId),
|
||||||
|
{ staleTime: MINUTE_MS }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUserBets = (userId: string) => {
|
export const useUserBets = (userId: string) => {
|
||||||
|
|
|
@ -6,7 +6,8 @@ import { useFollows } from './use-follows'
|
||||||
import { useUser } from './use-user'
|
import { useUser } from './use-user'
|
||||||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||||
import { DocumentData } from 'firebase/firestore'
|
import { DocumentData } from 'firebase/firestore'
|
||||||
import { users, privateUsers } from 'web/lib/firebase/users'
|
import { users, privateUsers, getUsers } from 'web/lib/firebase/users'
|
||||||
|
import { QueryClient } from 'react-query'
|
||||||
|
|
||||||
export const useUsers = () => {
|
export const useUsers = () => {
|
||||||
const result = useFirestoreQueryData<DocumentData, User[]>(['users'], users, {
|
const result = useFirestoreQueryData<DocumentData, User[]>(['users'], users, {
|
||||||
|
@ -16,6 +17,10 @@ export const useUsers = () => {
|
||||||
return result.data ?? []
|
return result.data ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const q = new QueryClient()
|
||||||
|
export const getCachedUsers = async () =>
|
||||||
|
q.fetchQuery(['users'], getUsers, { staleTime: Infinity })
|
||||||
|
|
||||||
export const usePrivateUsers = () => {
|
export const usePrivateUsers = () => {
|
||||||
const result = useFirestoreQueryData<DocumentData, PrivateUser[]>(
|
const result = useFirestoreQueryData<DocumentData, PrivateUser[]>(
|
||||||
['private users'],
|
['private users'],
|
||||||
|
|
|
@ -24,7 +24,6 @@ import { Contract } from 'common/contract'
|
||||||
import { getContractFromId, updateContract } from 'web/lib/firebase/contracts'
|
import { getContractFromId, updateContract } from 'web/lib/firebase/contracts'
|
||||||
import { db } from 'web/lib/firebase/init'
|
import { db } from 'web/lib/firebase/init'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
import { getUser } from 'web/lib/firebase/users'
|
|
||||||
|
|
||||||
export const groups = coll<Group>('groups')
|
export const groups = coll<Group>('groups')
|
||||||
export const groupMembers = (groupId: string) =>
|
export const groupMembers = (groupId: string) =>
|
||||||
|
@ -253,7 +252,7 @@ export function getGroupLinkToDisplay(contract: Contract) {
|
||||||
return groupToDisplay
|
return groupToDisplay
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMembers(group: Group) {
|
export async function listMemberIds(group: Group) {
|
||||||
const members = await getValues<GroupMemberDoc>(groupMembers(group.id))
|
const members = await getValues<GroupMemberDoc>(groupMembers(group.id))
|
||||||
return await Promise.all(members.map((m) => m.userId).map(getUser))
|
return members.map((m) => m.userId)
|
||||||
}
|
}
|
||||||
|
|
20
web/lib/icons/bold-icon.tsx
Normal file
20
web/lib/icons/bold-icon.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// from Feather: https://feathericons.com/
|
||||||
|
export default function BoldIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
|
||||||
|
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
21
web/lib/icons/italic-icon.tsx
Normal file
21
web/lib/icons/italic-icon.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
// from Feather: https://feathericons.com/
|
||||||
|
export default function ItalicIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<line x1="19" y1="4" x2="10" y2="4"></line>
|
||||||
|
<line x1="14" y1="20" x2="5" y2="20"></line>
|
||||||
|
<line x1="15" y1="4" x2="9" y2="20"></line>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
20
web/lib/icons/link-icon.tsx
Normal file
20
web/lib/icons/link-icon.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// from Feather: https://feathericons.com/
|
||||||
|
export default function LinkIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
|
@ -12,3 +12,7 @@ export function isIOS() {
|
||||||
(navigator.userAgent.includes('Mac') && 'ontouchend' in document)
|
(navigator.userAgent.includes('Mac') && 'ontouchend' in document)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAndroid() {
|
||||||
|
return navigator.userAgent.includes('Android')
|
||||||
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { Comment } from 'common/comment'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { DOMAIN, ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
import { richTextToString } from 'common/util/parse'
|
import { richTextToString } from 'common/util/parse'
|
||||||
|
|
||||||
|
@ -121,7 +121,7 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
||||||
: closeTime,
|
: closeTime,
|
||||||
question,
|
question,
|
||||||
tags,
|
tags,
|
||||||
url: `https://manifold.markets/${creatorUsername}/${slug}`,
|
url: `https://${DOMAIN}/${creatorUsername}/${slug}`,
|
||||||
pool,
|
pool,
|
||||||
probability,
|
probability,
|
||||||
p,
|
p,
|
||||||
|
|
|
@ -178,7 +178,7 @@ export default function Charity(props: {
|
||||||
className="input input-bordered mb-6 w-full"
|
className="input input-bordered mb-6 w-full"
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 lg:max-w-full lg:grid-cols-2 xl:grid-cols-3">
|
<div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 self-center lg:max-w-full lg:grid-cols-2 xl:grid-cols-3">
|
||||||
{filterCharities.map((charity) => (
|
{filterCharities.map((charity) => (
|
||||||
<CharityCard
|
<CharityCard
|
||||||
charity={charity}
|
charity={charity}
|
||||||
|
@ -203,18 +203,26 @@ export default function Charity(props: {
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 text-gray-500">
|
<div className="prose mt-10 max-w-none text-gray-500">
|
||||||
<span className="font-semibold">Notes</span>
|
<span className="text-lg font-semibold">Notes</span>
|
||||||
<br />
|
<ul>
|
||||||
- Don't see your favorite charity? Recommend it by emailing
|
<li>
|
||||||
charity@manifold.markets!
|
Don't see your favorite charity? Recommend it by emailing{' '}
|
||||||
<br />
|
<a href="mailto:charity@manifold.markets?subject=Add%20Charity">
|
||||||
- Manifold is not affiliated with non-Featured charities; we're just
|
charity@manifold.markets
|
||||||
fans of their work.
|
</a>
|
||||||
<br />
|
!
|
||||||
- As Manifold itself is a for-profit entity, your contributions will
|
</li>
|
||||||
not be tax deductible.
|
<li>
|
||||||
<br />- Donations + matches are wired once each quarter.
|
Manifold is not affiliated with non-Featured charities; we're just
|
||||||
|
fans of their work.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
As Manifold itself is a for-profit entity, your contributions will
|
||||||
|
not be tax deductible.
|
||||||
|
</li>
|
||||||
|
<li>Donations + matches are wired once each quarter.</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
|
@ -16,14 +16,9 @@ export default function Home() {
|
||||||
|
|
||||||
useTracking('edit home')
|
useTracking('edit home')
|
||||||
|
|
||||||
const [homeSections, setHomeSections] = useState(
|
const [homeSections, setHomeSections] = useState(user?.homeSections ?? [])
|
||||||
user?.homeSections ?? { visible: [], hidden: [] }
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateHomeSections = (newHomeSections: {
|
const updateHomeSections = (newHomeSections: string[]) => {
|
||||||
visible: string[]
|
|
||||||
hidden: string[]
|
|
||||||
}) => {
|
|
||||||
if (!user) return
|
if (!user) return
|
||||||
updateUser(user.id, { homeSections: newHomeSections })
|
updateUser(user.id, { homeSections: newHomeSections })
|
||||||
setHomeSections(newHomeSections)
|
setHomeSections(newHomeSections)
|
||||||
|
@ -31,7 +26,7 @@ export default function Home() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<Col className="pm:mx-10 gap-4 px-4 pb-12">
|
<Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2">
|
||||||
<Row className={'w-full items-center justify-between'}>
|
<Row className={'w-full items-center justify-between'}>
|
||||||
<Title text="Edit your home page" />
|
<Title text="Edit your home page" />
|
||||||
<DoneButton />
|
<DoneButton />
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
import Router from 'next/router'
|
import Router from 'next/router'
|
||||||
import {
|
import {
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
|
@ -28,6 +28,7 @@ import { groupPath } from 'web/lib/firebase/groups'
|
||||||
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
||||||
import { calculatePortfolioProfit } from 'common/calculate-metrics'
|
import { calculatePortfolioProfit } from 'common/calculate-metrics'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
|
import { useProbChanges } from 'web/hooks/use-prob-changes'
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
@ -38,10 +39,7 @@ const Home = () => {
|
||||||
|
|
||||||
const groups = useMemberGroups(user?.id) ?? []
|
const groups = useMemberGroups(user?.id) ?? []
|
||||||
|
|
||||||
const [homeSections] = useState(
|
const { sections } = getHomeItems(groups, user?.homeSections ?? [])
|
||||||
user?.homeSections ?? { visible: [], hidden: [] }
|
|
||||||
)
|
|
||||||
const { visibleItems } = getHomeItems(groups, homeSections)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
@ -54,29 +52,19 @@ const Home = () => {
|
||||||
|
|
||||||
<DailyProfitAndBalance userId={user?.id} />
|
<DailyProfitAndBalance userId={user?.id} />
|
||||||
|
|
||||||
<div className="text-xl text-gray-800">Daily movers</div>
|
{sections.map((item) => {
|
||||||
<ProbChangeTable userId={user?.id} />
|
|
||||||
|
|
||||||
{visibleItems.map((item) => {
|
|
||||||
const { id } = item
|
const { id } = item
|
||||||
if (id === 'your-bets') {
|
if (id === 'daily-movers') {
|
||||||
return (
|
return <DailyMoversSection key={id} userId={user?.id} />
|
||||||
<SearchSection
|
|
||||||
key={id}
|
|
||||||
label={'Your trades'}
|
|
||||||
sort={'newest'}
|
|
||||||
user={user}
|
|
||||||
yourBets
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const sort = SORTS.find((sort) => sort.value === id)
|
const sort = SORTS.find((sort) => sort.value === id)
|
||||||
if (sort)
|
if (sort)
|
||||||
return (
|
return (
|
||||||
<SearchSection
|
<SearchSection
|
||||||
key={id}
|
key={id}
|
||||||
label={sort.label}
|
label={sort.value === 'newest' ? 'New for you' : sort.label}
|
||||||
sort={sort.value}
|
sort={sort.value}
|
||||||
|
followed={sort.value === 'newest'}
|
||||||
user={user}
|
user={user}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
@ -103,11 +91,12 @@ const Home = () => {
|
||||||
|
|
||||||
function SearchSection(props: {
|
function SearchSection(props: {
|
||||||
label: string
|
label: string
|
||||||
user: User | null | undefined
|
user: User | null | undefined | undefined
|
||||||
sort: Sort
|
sort: Sort
|
||||||
yourBets?: boolean
|
yourBets?: boolean
|
||||||
|
followed?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { label, user, sort, yourBets } = props
|
const { label, user, sort, yourBets, followed } = props
|
||||||
const href = `/home?s=${sort}`
|
const href = `/home?s=${sort}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -122,7 +111,13 @@ function SearchSection(props: {
|
||||||
<ContractSearch
|
<ContractSearch
|
||||||
user={user}
|
user={user}
|
||||||
defaultSort={sort}
|
defaultSort={sort}
|
||||||
additionalFilter={yourBets ? { yourBets: true } : { followed: true }}
|
additionalFilter={
|
||||||
|
yourBets
|
||||||
|
? { yourBets: true }
|
||||||
|
: followed
|
||||||
|
? { followed: true }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
noControls
|
noControls
|
||||||
maxResults={6}
|
maxResults={6}
|
||||||
persistPrefix={`experimental-home-${sort}`}
|
persistPrefix={`experimental-home-${sort}`}
|
||||||
|
@ -131,7 +126,10 @@ function SearchSection(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupSection(props: { group: Group; user: User | null | undefined }) {
|
function GroupSection(props: {
|
||||||
|
group: Group
|
||||||
|
user: User | null | undefined | undefined
|
||||||
|
}) {
|
||||||
const { group, user } = props
|
const { group, user } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -155,6 +153,24 @@ function GroupSection(props: { group: Group; user: User | null | undefined }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DailyMoversSection(props: { userId: string | null | undefined }) {
|
||||||
|
const { userId } = props
|
||||||
|
const changes = useProbChanges(userId ?? '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col className="gap-2">
|
||||||
|
<SiteLink className="text-xl" href={'/daily-movers'}>
|
||||||
|
Daily movers{' '}
|
||||||
|
<ArrowSmRightIcon
|
||||||
|
className="mb-0.5 inline h-6 w-6 text-gray-500"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</SiteLink>
|
||||||
|
<ProbChangeTable changes={changes} />
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function EditButton(props: { className?: string }) {
|
function EditButton(props: { className?: string }) {
|
||||||
const { className } = props
|
const { className } = props
|
||||||
|
|
||||||
|
@ -186,14 +202,14 @@ function DailyProfitAndBalance(props: {
|
||||||
return (
|
return (
|
||||||
<div className={clsx(className, 'text-lg')}>
|
<div className={clsx(className, 'text-lg')}>
|
||||||
<span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}>
|
<span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}>
|
||||||
{profit >= 0 ? '+' : '-'}
|
{profit >= 0 && '+'}
|
||||||
{formatMoney(profit)}
|
{formatMoney(profit)}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
profit and{' '}
|
profit and{' '}
|
||||||
<span
|
<span
|
||||||
className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')}
|
className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')}
|
||||||
>
|
>
|
||||||
{balanceChange >= 0 ? '+' : '-'}
|
{balanceChange >= 0 && '+'}
|
||||||
{formatMoney(balanceChange)}
|
{formatMoney(balanceChange)}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
balance today
|
balance today
|
||||||
|
|
|
@ -1,28 +1,28 @@
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { debounce, sortBy, take } from 'lodash'
|
|
||||||
import { SearchIcon } from '@heroicons/react/outline'
|
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
|
|
||||||
import { Group, GROUP_CHAT_SLUG } from 'common/group'
|
import { Group, GROUP_CHAT_SLUG } from 'common/group'
|
||||||
import { Page } from 'web/components/page'
|
import { Page } from 'web/components/page'
|
||||||
import { listAllBets } from 'web/lib/firebase/bets'
|
|
||||||
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
|
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
|
||||||
import {
|
import {
|
||||||
addContractToGroup,
|
addContractToGroup,
|
||||||
getGroupBySlug,
|
getGroupBySlug,
|
||||||
groupPath,
|
groupPath,
|
||||||
joinGroup,
|
joinGroup,
|
||||||
listMembers,
|
listMemberIds,
|
||||||
updateGroup,
|
updateGroup,
|
||||||
} from 'web/lib/firebase/groups'
|
} from 'web/lib/firebase/groups'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group'
|
import {
|
||||||
import { scoreCreators, scoreTraders } from 'common/scoring'
|
useGroup,
|
||||||
|
useGroupContractIds,
|
||||||
|
useMemberIds,
|
||||||
|
} from 'web/hooks/use-group'
|
||||||
import { Leaderboard } from 'web/components/leaderboard'
|
import { Leaderboard } from 'web/components/leaderboard'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { EditGroupButton } from 'web/components/groups/edit-group-button'
|
import { EditGroupButton } from 'web/components/groups/edit-group-button'
|
||||||
|
@ -35,9 +35,7 @@ import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||||
import { ContractSearch } from 'web/components/contract-search'
|
import { ContractSearch } from 'web/components/contract-search'
|
||||||
import { FollowList } from 'web/components/follow-list'
|
|
||||||
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
||||||
import { searchInAny } from 'common/util/parse'
|
|
||||||
import { CopyLinkButton } from 'web/components/copy-link-button'
|
import { CopyLinkButton } from 'web/components/copy-link-button'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||||
|
@ -59,7 +57,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||||
const { slugs } = props.params
|
const { slugs } = props.params
|
||||||
|
|
||||||
const group = await getGroupBySlug(slugs[0])
|
const group = await getGroupBySlug(slugs[0])
|
||||||
const members = group && (await listMembers(group))
|
const memberIds = group && (await listMemberIds(group))
|
||||||
const creatorPromise = group ? getUser(group.creatorId) : null
|
const creatorPromise = group ? getUser(group.creatorId) : null
|
||||||
|
|
||||||
const contracts =
|
const contracts =
|
||||||
|
@ -71,33 +69,24 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||||
: 'open'
|
: 'open'
|
||||||
const aboutPost =
|
const aboutPost =
|
||||||
group && group.aboutPostId != null && (await getPost(group.aboutPostId))
|
group && group.aboutPostId != null && (await getPost(group.aboutPostId))
|
||||||
const bets = await Promise.all(
|
|
||||||
contracts.map((contract: Contract) => listAllBets(contract.id))
|
|
||||||
)
|
|
||||||
const messages = group && (await listAllCommentsOnGroup(group.id))
|
const messages = group && (await listAllCommentsOnGroup(group.id))
|
||||||
|
|
||||||
const creatorScores = scoreCreators(contracts)
|
const cachedTopTraderIds =
|
||||||
const traderScores = scoreTraders(contracts, bets)
|
(group && group.cachedLeaderboard?.topTraders) ?? []
|
||||||
const [topCreators, topTraders] =
|
const cachedTopCreatorIds =
|
||||||
(members && [
|
(group && group.cachedLeaderboard?.topCreators) ?? []
|
||||||
toTopUsers(creatorScores, members),
|
const topTraders = await toTopUsers(cachedTopTraderIds)
|
||||||
toTopUsers(traderScores, members),
|
|
||||||
]) ??
|
const topCreators = await toTopUsers(cachedTopCreatorIds)
|
||||||
[]
|
|
||||||
|
|
||||||
const creator = await creatorPromise
|
const creator = await creatorPromise
|
||||||
// Only count unresolved markets
|
|
||||||
const contractsCount = contracts.filter((c) => !c.isResolved).length
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
contractsCount,
|
|
||||||
group,
|
group,
|
||||||
members,
|
memberIds,
|
||||||
creator,
|
creator,
|
||||||
traderScores,
|
|
||||||
topTraders,
|
topTraders,
|
||||||
creatorScores,
|
|
||||||
topCreators,
|
topCreators,
|
||||||
messages,
|
messages,
|
||||||
aboutPost,
|
aboutPost,
|
||||||
|
@ -107,19 +96,6 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||||
revalidate: 60, // regenerate after a minute
|
revalidate: 60, // regenerate after a minute
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toTopUsers(userScores: { [userId: string]: number }, users: User[]) {
|
|
||||||
const topUserPairs = take(
|
|
||||||
sortBy(Object.entries(userScores), ([_, score]) => -1 * score),
|
|
||||||
10
|
|
||||||
).filter(([_, score]) => score >= 0.5)
|
|
||||||
|
|
||||||
const topUsers = topUserPairs.map(
|
|
||||||
([userId]) => users.filter((user) => user.id === userId)[0]
|
|
||||||
)
|
|
||||||
return topUsers.filter((user) => user)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
return { paths: [], fallback: 'blocking' }
|
return { paths: [], fallback: 'blocking' }
|
||||||
}
|
}
|
||||||
|
@ -132,39 +108,25 @@ const groupSubpages = [
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export default function GroupPage(props: {
|
export default function GroupPage(props: {
|
||||||
contractsCount: number
|
|
||||||
group: Group | null
|
group: Group | null
|
||||||
members: User[]
|
memberIds: string[]
|
||||||
creator: User
|
creator: User
|
||||||
traderScores: { [userId: string]: number }
|
topTraders: { user: User; score: number }[]
|
||||||
topTraders: User[]
|
topCreators: { user: User; score: number }[]
|
||||||
creatorScores: { [userId: string]: number }
|
|
||||||
topCreators: User[]
|
|
||||||
messages: GroupComment[]
|
messages: GroupComment[]
|
||||||
aboutPost: Post
|
aboutPost: Post
|
||||||
suggestedFilter: 'open' | 'all'
|
suggestedFilter: 'open' | 'all'
|
||||||
}) {
|
}) {
|
||||||
props = usePropz(props, getStaticPropz) ?? {
|
props = usePropz(props, getStaticPropz) ?? {
|
||||||
contractsCount: 0,
|
|
||||||
group: null,
|
group: null,
|
||||||
members: [],
|
memberIds: [],
|
||||||
creator: null,
|
creator: null,
|
||||||
traderScores: {},
|
|
||||||
topTraders: [],
|
topTraders: [],
|
||||||
creatorScores: {},
|
|
||||||
topCreators: [],
|
topCreators: [],
|
||||||
messages: [],
|
messages: [],
|
||||||
suggestedFilter: 'open',
|
suggestedFilter: 'open',
|
||||||
}
|
}
|
||||||
const {
|
const { creator, topTraders, topCreators, suggestedFilter } = props
|
||||||
contractsCount,
|
|
||||||
creator,
|
|
||||||
traderScores,
|
|
||||||
topTraders,
|
|
||||||
creatorScores,
|
|
||||||
topCreators,
|
|
||||||
suggestedFilter,
|
|
||||||
} = props
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { slugs } = router.query as { slugs: string[] }
|
const { slugs } = router.query as { slugs: string[] }
|
||||||
|
@ -175,7 +137,7 @@ export default function GroupPage(props: {
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const isAdmin = useAdmin()
|
const isAdmin = useAdmin()
|
||||||
const members = useMembers(group?.id) ?? props.members
|
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
|
||||||
|
|
||||||
useSaveReferral(user, {
|
useSaveReferral(user, {
|
||||||
defaultReferrerUsername: creator.username,
|
defaultReferrerUsername: creator.username,
|
||||||
|
@ -186,18 +148,25 @@ export default function GroupPage(props: {
|
||||||
return <Custom404 />
|
return <Custom404 />
|
||||||
}
|
}
|
||||||
const isCreator = user && group && user.id === group.creatorId
|
const isCreator = user && group && user.id === group.creatorId
|
||||||
const isMember = user && members.map((m) => m.id).includes(user.id)
|
const isMember = user && memberIds.includes(user.id)
|
||||||
|
const maxLeaderboardSize = 50
|
||||||
|
|
||||||
const leaderboard = (
|
const leaderboard = (
|
||||||
<Col>
|
<Col>
|
||||||
<GroupLeaderboards
|
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
|
||||||
traderScores={traderScores}
|
<GroupLeaderboard
|
||||||
creatorScores={creatorScores}
|
topUsers={topTraders}
|
||||||
topTraders={topTraders}
|
title="🏅 Top traders"
|
||||||
topCreators={topCreators}
|
header="Profit"
|
||||||
members={members}
|
maxToShow={maxLeaderboardSize}
|
||||||
user={user}
|
/>
|
||||||
/>
|
<GroupLeaderboard
|
||||||
|
topUsers={topCreators}
|
||||||
|
title="🏅 Top creators"
|
||||||
|
header="Market volume"
|
||||||
|
maxToShow={maxLeaderboardSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -216,7 +185,7 @@ export default function GroupPage(props: {
|
||||||
creator={creator}
|
creator={creator}
|
||||||
isCreator={!!isCreator}
|
isCreator={!!isCreator}
|
||||||
user={user}
|
user={user}
|
||||||
members={members}
|
memberIds={memberIds}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
|
@ -233,7 +202,6 @@ export default function GroupPage(props: {
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
badge: `${contractsCount}`,
|
|
||||||
title: 'Markets',
|
title: 'Markets',
|
||||||
content: questionsTab,
|
content: questionsTab,
|
||||||
href: groupPath(group.slug, 'markets'),
|
href: groupPath(group.slug, 'markets'),
|
||||||
|
@ -312,9 +280,9 @@ function GroupOverview(props: {
|
||||||
creator: User
|
creator: User
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
isCreator: boolean
|
isCreator: boolean
|
||||||
members: User[]
|
memberIds: string[]
|
||||||
}) {
|
}) {
|
||||||
const { group, creator, isCreator, user, members } = props
|
const { group, creator, isCreator, user, memberIds } = props
|
||||||
const anyoneCanJoinChoices: { [key: string]: string } = {
|
const anyoneCanJoinChoices: { [key: string]: string } = {
|
||||||
Closed: 'false',
|
Closed: 'false',
|
||||||
Open: 'true',
|
Open: 'true',
|
||||||
|
@ -333,7 +301,7 @@ function GroupOverview(props: {
|
||||||
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
|
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
|
||||||
group.slug
|
group.slug
|
||||||
)}${postFix}`
|
)}${postFix}`
|
||||||
const isMember = user ? members.map((m) => m.id).includes(user.id) : false
|
const isMember = user ? memberIds.includes(user.id) : false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -399,155 +367,37 @@ function GroupOverview(props: {
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Col className={'mt-2'}>
|
|
||||||
<div className="mb-2 text-lg">Members</div>
|
|
||||||
<GroupMemberSearch members={members} group={group} />
|
|
||||||
</Col>
|
|
||||||
</Col>
|
</Col>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchBar(props: { setQuery: (query: string) => void }) {
|
function GroupLeaderboard(props: {
|
||||||
const { setQuery } = props
|
topUsers: { user: User; score: number }[]
|
||||||
const debouncedQuery = debounce(setQuery, 50)
|
|
||||||
return (
|
|
||||||
<div className={'relative'}>
|
|
||||||
<SearchIcon className={'absolute left-5 top-3.5 h-5 w-5 text-gray-500'} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
onChange={(e) => debouncedQuery(e.target.value)}
|
|
||||||
placeholder="Find a member"
|
|
||||||
className="input input-bordered mb-4 w-full pl-12"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function GroupMemberSearch(props: { members: User[]; group: Group }) {
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const { group } = props
|
|
||||||
let { members } = props
|
|
||||||
|
|
||||||
// Use static members on load, but also listen to member changes:
|
|
||||||
const listenToMembers = useMembers(group.id)
|
|
||||||
if (listenToMembers) {
|
|
||||||
members = listenToMembers
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO use find-active-contracts to sort by?
|
|
||||||
const matches = sortBy(members, [(member) => member.name]).filter((m) =>
|
|
||||||
searchInAny(query, m.name, m.username)
|
|
||||||
)
|
|
||||||
const matchLimit = 25
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SearchBar setQuery={setQuery} />
|
|
||||||
<Col className={'gap-2'}>
|
|
||||||
{matches.length > 0 && (
|
|
||||||
<FollowList userIds={matches.slice(0, matchLimit).map((m) => m.id)} />
|
|
||||||
)}
|
|
||||||
{matches.length > 25 && (
|
|
||||||
<div className={'text-center'}>
|
|
||||||
And {matches.length - matchLimit} more...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Col>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortedLeaderboard(props: {
|
|
||||||
users: User[]
|
|
||||||
scoreFunction: (user: User) => number
|
|
||||||
title: string
|
title: string
|
||||||
|
maxToShow: number
|
||||||
header: string
|
header: string
|
||||||
maxToShow?: number
|
|
||||||
}) {
|
}) {
|
||||||
const { users, scoreFunction, title, header, maxToShow } = props
|
const { topUsers, title, maxToShow, header } = props
|
||||||
const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a))
|
|
||||||
|
const scoresByUser = topUsers.reduce((acc, { user, score }) => {
|
||||||
|
acc[user.id] = score
|
||||||
|
return acc
|
||||||
|
}, {} as { [key: string]: number })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Leaderboard
|
<Leaderboard
|
||||||
className="max-w-xl"
|
className="max-w-xl"
|
||||||
users={sortedUsers}
|
users={topUsers.map((t) => t.user)}
|
||||||
title={title}
|
title={title}
|
||||||
columns={[
|
columns={[
|
||||||
{ header, renderCell: (user) => formatMoney(scoreFunction(user)) },
|
{ header, renderCell: (user) => formatMoney(scoresByUser[user.id]) },
|
||||||
]}
|
]}
|
||||||
maxToShow={maxToShow}
|
maxToShow={maxToShow}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupLeaderboards(props: {
|
|
||||||
traderScores: { [userId: string]: number }
|
|
||||||
creatorScores: { [userId: string]: number }
|
|
||||||
topTraders: User[]
|
|
||||||
topCreators: User[]
|
|
||||||
members: User[]
|
|
||||||
user: User | null | undefined
|
|
||||||
}) {
|
|
||||||
const { traderScores, creatorScores, members, topTraders, topCreators } =
|
|
||||||
props
|
|
||||||
const maxToShow = 50
|
|
||||||
// Consider hiding M$0
|
|
||||||
// If it's just one member (curator), show all bettors, otherwise just show members
|
|
||||||
return (
|
|
||||||
<Col>
|
|
||||||
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
|
|
||||||
{members.length > 1 ? (
|
|
||||||
<>
|
|
||||||
<SortedLeaderboard
|
|
||||||
users={members}
|
|
||||||
scoreFunction={(user) => traderScores[user.id] ?? 0}
|
|
||||||
title="🏅 Top traders"
|
|
||||||
header="Profit"
|
|
||||||
maxToShow={maxToShow}
|
|
||||||
/>
|
|
||||||
<SortedLeaderboard
|
|
||||||
users={members}
|
|
||||||
scoreFunction={(user) => creatorScores[user.id] ?? 0}
|
|
||||||
title="🏅 Top creators"
|
|
||||||
header="Market volume"
|
|
||||||
maxToShow={maxToShow}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Leaderboard
|
|
||||||
className="max-w-xl"
|
|
||||||
title="🏅 Top traders"
|
|
||||||
users={topTraders}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
header: 'Profit',
|
|
||||||
renderCell: (user) => formatMoney(traderScores[user.id] ?? 0),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
maxToShow={maxToShow}
|
|
||||||
/>
|
|
||||||
<Leaderboard
|
|
||||||
className="max-w-xl"
|
|
||||||
title="🏅 Top creators"
|
|
||||||
users={topCreators}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
header: 'Market volume',
|
|
||||||
renderCell: (user) =>
|
|
||||||
formatMoney(creatorScores[user.id] ?? 0),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
maxToShow={maxToShow}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddContractButton(props: { group: Group; user: User }) {
|
function AddContractButton(props: { group: Group; user: User }) {
|
||||||
const { group, user } = props
|
const { group, user } = props
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
@ -684,3 +534,15 @@ function JoinGroupButton(props: {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toTopUsers = async (
|
||||||
|
cachedUserIds: { userId: string; score: number }[]
|
||||||
|
): Promise<{ user: User; score: number }[]> =>
|
||||||
|
(
|
||||||
|
await Promise.all(
|
||||||
|
cachedUserIds.map(async (e) => {
|
||||||
|
const user = await getUser(e.userId)
|
||||||
|
return { user, score: e.score ?? 0 }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).filter((e) => e.user != null)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Tabs } from 'web/components/layout/tabs'
|
import { ControlledTabs } from 'web/components/layout/tabs'
|
||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import Router from 'next/router'
|
import Router, { useRouter } from 'next/router'
|
||||||
import { Notification, notification_source_types } from 'common/notification'
|
import { Notification, notification_source_types } from 'common/notification'
|
||||||
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
|
@ -26,6 +26,7 @@ import {
|
||||||
import {
|
import {
|
||||||
NotificationGroup,
|
NotificationGroup,
|
||||||
useGroupedNotifications,
|
useGroupedNotifications,
|
||||||
|
useUnseenGroupedNotification,
|
||||||
} from 'web/hooks/use-notifications'
|
} from 'web/hooks/use-notifications'
|
||||||
import { TrendingUpIcon } from '@heroicons/react/outline'
|
import { TrendingUpIcon } from '@heroicons/react/outline'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
|
@ -40,7 +41,7 @@ import { Pagination } from 'web/components/pagination'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
import { safeLocalStorage } from 'web/lib/util/local'
|
import { safeLocalStorage } from 'web/lib/util/local'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import { NotificationSettings } from 'web/components/NotificationSettings'
|
import { NotificationSettings } from 'web/components/notification-settings'
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||||
import { UserLink } from 'web/components/user-link'
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
@ -56,24 +57,51 @@ const HIGHLIGHT_CLASS = 'bg-indigo-50'
|
||||||
|
|
||||||
export default function Notifications() {
|
export default function Notifications() {
|
||||||
const privateUser = usePrivateUser()
|
const privateUser = usePrivateUser()
|
||||||
|
const router = useRouter()
|
||||||
|
const [navigateToSection, setNavigateToSection] = useState<string>()
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (privateUser === null) Router.push('/')
|
if (privateUser === null) Router.push('/')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = { ...router.query }
|
||||||
|
if (query.tab === 'settings') {
|
||||||
|
setActiveIndex(1)
|
||||||
|
}
|
||||||
|
if (query.section) {
|
||||||
|
setNavigateToSection(query.section as string)
|
||||||
|
}
|
||||||
|
}, [router.query])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<div className={'px-2 pt-4 sm:px-4 lg:pt-0'}>
|
<div className={'px-2 pt-4 sm:px-4 lg:pt-0'}>
|
||||||
<Title text={'Notifications'} className={'hidden md:block'} />
|
<Title text={'Notifications'} className={'hidden md:block'} />
|
||||||
<SEO title="Notifications" description="Manifold user notifications" />
|
<SEO title="Notifications" description="Manifold user notifications" />
|
||||||
|
|
||||||
{privateUser && (
|
{privateUser && router.isReady && (
|
||||||
<div>
|
<div>
|
||||||
<Tabs
|
<ControlledTabs
|
||||||
currentPageForAnalytics={'notifications'}
|
currentPageForAnalytics={'notifications'}
|
||||||
labelClassName={'pb-2 pt-1 '}
|
labelClassName={'pb-2 pt-1 '}
|
||||||
className={'mb-0 sm:mb-2'}
|
className={'mb-0 sm:mb-2'}
|
||||||
defaultIndex={0}
|
activeIndex={activeIndex}
|
||||||
|
onClick={(title, i) => {
|
||||||
|
router.replace(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
...router.query,
|
||||||
|
tab: title.toLowerCase(),
|
||||||
|
section: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ shallow: true }
|
||||||
|
)
|
||||||
|
setActiveIndex(i)
|
||||||
|
}}
|
||||||
tabs={[
|
tabs={[
|
||||||
{
|
{
|
||||||
title: 'Notifications',
|
title: 'Notifications',
|
||||||
|
@ -82,9 +110,9 @@ export default function Notifications() {
|
||||||
{
|
{
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
content: (
|
content: (
|
||||||
<div className={''}>
|
<NotificationSettings
|
||||||
<NotificationSettings />
|
navigateToSection={navigateToSection}
|
||||||
</div>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
@ -128,16 +156,13 @@ function NotificationsList(props: { privateUser: PrivateUser }) {
|
||||||
const { privateUser } = props
|
const { privateUser } = props
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const allGroupedNotifications = useGroupedNotifications(privateUser)
|
const allGroupedNotifications = useGroupedNotifications(privateUser)
|
||||||
|
const unseenGroupedNotifications = useUnseenGroupedNotification(privateUser)
|
||||||
const paginatedGroupedNotifications = useMemo(() => {
|
const paginatedGroupedNotifications = useMemo(() => {
|
||||||
if (!allGroupedNotifications) return
|
if (!allGroupedNotifications) return
|
||||||
const start = page * NOTIFICATIONS_PER_PAGE
|
const start = page * NOTIFICATIONS_PER_PAGE
|
||||||
const end = start + NOTIFICATIONS_PER_PAGE
|
const end = start + NOTIFICATIONS_PER_PAGE
|
||||||
const maxNotificationsToShow = allGroupedNotifications.slice(start, end)
|
const maxNotificationsToShow = allGroupedNotifications.slice(start, end)
|
||||||
const remainingNotification = allGroupedNotifications.slice(end)
|
|
||||||
for (const notification of remainingNotification) {
|
|
||||||
if (notification.isSeen) break
|
|
||||||
else setNotificationsAsSeen(notification.notifications)
|
|
||||||
}
|
|
||||||
const local = safeLocalStorage()
|
const local = safeLocalStorage()
|
||||||
local?.setItem(
|
local?.setItem(
|
||||||
'notification-groups',
|
'notification-groups',
|
||||||
|
@ -146,6 +171,19 @@ function NotificationsList(props: { privateUser: PrivateUser }) {
|
||||||
return maxNotificationsToShow
|
return maxNotificationsToShow
|
||||||
}, [allGroupedNotifications, page])
|
}, [allGroupedNotifications, page])
|
||||||
|
|
||||||
|
// Set all notifications that don't fit on the first page to seen
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
paginatedGroupedNotifications &&
|
||||||
|
paginatedGroupedNotifications?.length >= NOTIFICATIONS_PER_PAGE
|
||||||
|
) {
|
||||||
|
const allUnseenNotifications = unseenGroupedNotifications
|
||||||
|
?.map((ng) => ng.notifications)
|
||||||
|
.flat()
|
||||||
|
allUnseenNotifications && setNotificationsAsSeen(allUnseenNotifications)
|
||||||
|
}
|
||||||
|
}, [paginatedGroupedNotifications, unseenGroupedNotifications])
|
||||||
|
|
||||||
if (!paginatedGroupedNotifications || !allGroupedNotifications)
|
if (!paginatedGroupedNotifications || !allGroupedNotifications)
|
||||||
return <LoadingIndicator />
|
return <LoadingIndicator />
|
||||||
|
|
||||||
|
@ -992,51 +1030,54 @@ function getReasonForShowingNotification(
|
||||||
) {
|
) {
|
||||||
const { sourceType, sourceUpdateType, reason, sourceSlug } = notification
|
const { sourceType, sourceUpdateType, reason, sourceSlug } = notification
|
||||||
let reasonText: string
|
let reasonText: string
|
||||||
switch (sourceType) {
|
// TODO: we could leave out this switch and just use the reason field now that they have more information
|
||||||
case 'comment':
|
if (reason === 'tagged_user')
|
||||||
if (reason === 'reply_to_users_answer')
|
reasonText = justSummary ? 'tagged you' : 'tagged you on'
|
||||||
reasonText = justSummary ? 'replied' : 'replied to you on'
|
else
|
||||||
else if (reason === 'tagged_user')
|
switch (sourceType) {
|
||||||
reasonText = justSummary ? 'tagged you' : 'tagged you on'
|
case 'comment':
|
||||||
else if (reason === 'reply_to_users_comment')
|
if (reason === 'reply_to_users_answer')
|
||||||
reasonText = justSummary ? 'replied' : 'replied to you on'
|
reasonText = justSummary ? 'replied' : 'replied to you on'
|
||||||
else reasonText = justSummary ? `commented` : `commented on`
|
else if (reason === 'reply_to_users_comment')
|
||||||
break
|
reasonText = justSummary ? 'replied' : 'replied to you on'
|
||||||
case 'contract':
|
else reasonText = justSummary ? `commented` : `commented on`
|
||||||
if (reason === 'you_follow_user')
|
break
|
||||||
reasonText = justSummary ? 'asked the question' : 'asked'
|
case 'contract':
|
||||||
else if (sourceUpdateType === 'resolved')
|
if (reason === 'contract_from_followed_user')
|
||||||
reasonText = justSummary ? `resolved the question` : `resolved`
|
reasonText = justSummary ? 'asked the question' : 'asked'
|
||||||
else if (sourceUpdateType === 'closed') reasonText = `Please resolve`
|
else if (sourceUpdateType === 'resolved')
|
||||||
else reasonText = justSummary ? 'updated the question' : `updated`
|
reasonText = justSummary ? `resolved the question` : `resolved`
|
||||||
break
|
else if (sourceUpdateType === 'closed') reasonText = `Please resolve`
|
||||||
case 'answer':
|
else reasonText = justSummary ? 'updated the question' : `updated`
|
||||||
if (reason === 'on_users_contract') reasonText = `answered your question `
|
break
|
||||||
else reasonText = `answered`
|
case 'answer':
|
||||||
break
|
if (reason === 'answer_on_your_contract')
|
||||||
case 'follow':
|
reasonText = `answered your question `
|
||||||
reasonText = 'followed you'
|
else reasonText = `answered`
|
||||||
break
|
break
|
||||||
case 'liquidity':
|
case 'follow':
|
||||||
reasonText = 'added a subsidy to your question'
|
reasonText = 'followed you'
|
||||||
break
|
break
|
||||||
case 'group':
|
case 'liquidity':
|
||||||
reasonText = 'added you to the group'
|
reasonText = 'added a subsidy to your question'
|
||||||
break
|
break
|
||||||
case 'user':
|
case 'group':
|
||||||
if (sourceSlug && reason === 'user_joined_to_bet_on_your_market')
|
reasonText = 'added you to the group'
|
||||||
reasonText = 'joined to bet on your market'
|
break
|
||||||
else if (sourceSlug) reasonText = 'joined because you shared'
|
case 'user':
|
||||||
else reasonText = 'joined because of you'
|
if (sourceSlug && reason === 'user_joined_to_bet_on_your_market')
|
||||||
break
|
reasonText = 'joined to bet on your market'
|
||||||
case 'bet':
|
else if (sourceSlug) reasonText = 'joined because you shared'
|
||||||
reasonText = 'bet against you'
|
else reasonText = 'joined because of you'
|
||||||
break
|
break
|
||||||
case 'challenge':
|
case 'bet':
|
||||||
reasonText = 'accepted your challenge'
|
reasonText = 'bet against you'
|
||||||
break
|
break
|
||||||
default:
|
case 'challenge':
|
||||||
reasonText = ''
|
reasonText = 'accepted your challenge'
|
||||||
}
|
break
|
||||||
|
default:
|
||||||
|
reasonText = ''
|
||||||
|
}
|
||||||
return reasonText
|
return reasonText
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,10 +69,9 @@ export default function PostPage(props: {
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<div className="mx-auto w-full max-w-3xl ">
|
<div className="mx-auto w-full max-w-3xl ">
|
||||||
<Spacer h={1} />
|
<Title className="!mt-0 py-4 px-2" text={post.title} />
|
||||||
<Title className="!mt-0" text={post.title} />
|
|
||||||
<Row>
|
<Row>
|
||||||
<Col className="flex-1">
|
<Col className="flex-1 px-2">
|
||||||
<div className={'inline-flex'}>
|
<div className={'inline-flex'}>
|
||||||
<div className="mr-1 text-gray-500">Created by</div>
|
<div className="mr-1 text-gray-500">Created by</div>
|
||||||
<UserLink
|
<UserLink
|
||||||
|
@ -82,7 +81,7 @@ export default function PostPage(props: {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col>
|
<Col className="px-2">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
color="gray-white"
|
color="gray-white"
|
||||||
|
@ -116,7 +115,7 @@ export default function PostPage(props: {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Spacer h={2} />
|
<Spacer h={4} />
|
||||||
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
|
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
|
||||||
<PostCommentsActivity
|
<PostCommentsActivity
|
||||||
post={post}
|
post={post}
|
||||||
|
@ -145,7 +144,7 @@ export function PostCommentsActivity(props: {
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Col className="p-2">
|
||||||
<PostCommentInput post={post} />
|
<PostCommentInput post={post} />
|
||||||
{topLevelComments.map((parent) => (
|
{topLevelComments.map((parent) => (
|
||||||
<PostCommentThread
|
<PostCommentThread
|
||||||
|
@ -161,7 +160,7 @@ export function PostCommentsActivity(props: {
|
||||||
commentsByUserId={commentsByUserId}
|
commentsByUserId={commentsByUserId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -77,13 +77,21 @@ const Salem = {
|
||||||
|
|
||||||
const tourneys: Tourney[] = [
|
const tourneys: Tourney[] = [
|
||||||
{
|
{
|
||||||
title: 'Cause Exploration Prizes',
|
title: 'Manifold F2P Tournament',
|
||||||
blurb:
|
blurb:
|
||||||
'Which new charity ideas will Open Philanthropy find most promising?',
|
'Who can amass the most mana starting from a free-to-play (F2P) account?',
|
||||||
award: 'M$100k',
|
award: 'Poem',
|
||||||
endTime: toDate('Sep 9, 2022'),
|
endTime: toDate('Sep 15, 2022'),
|
||||||
groupId: 'cMcpBQ2p452jEcJD2SFw',
|
groupId: '6rrIja7tVW00lUVwtsYS',
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// title: 'Cause Exploration Prizes',
|
||||||
|
// blurb:
|
||||||
|
// 'Which new charity ideas will Open Philanthropy find most promising?',
|
||||||
|
// award: 'M$100k',
|
||||||
|
// endTime: toDate('Sep 9, 2022'),
|
||||||
|
// groupId: 'cMcpBQ2p452jEcJD2SFw',
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
title: 'Fantasy Football Stock Exchange',
|
title: 'Fantasy Football Stock Exchange',
|
||||||
blurb: 'How many points will each NFL player score this season?',
|
blurb: 'How many points will each NFL player score this season?',
|
||||||
|
@ -91,13 +99,6 @@ const tourneys: Tourney[] = [
|
||||||
endTime: toDate('Jan 6, 2023'),
|
endTime: toDate('Jan 6, 2023'),
|
||||||
groupId: 'SxGRqXRpV3RAQKudbcNb',
|
groupId: 'SxGRqXRpV3RAQKudbcNb',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'SF 2022 Ballot',
|
|
||||||
blurb: 'Which ballot initiatives will pass this year in SF and CA?',
|
|
||||||
award: '',
|
|
||||||
endTime: toDate('Nov 8, 2022'),
|
|
||||||
groupId: 'VkWZyS5yxs8XWUJrX9eq',
|
|
||||||
},
|
|
||||||
// {
|
// {
|
||||||
// title: 'Clearer Thinking Regrant Project',
|
// title: 'Clearer Thinking Regrant Project',
|
||||||
// blurb: 'Something amazing',
|
// blurb: 'Something amazing',
|
||||||
|
@ -105,6 +106,27 @@ const tourneys: Tourney[] = [
|
||||||
// endTime: toDate('Sep 22, 2022'),
|
// endTime: toDate('Sep 22, 2022'),
|
||||||
// groupId: '2VsVVFGhKtIdJnQRAXVb',
|
// groupId: '2VsVVFGhKtIdJnQRAXVb',
|
||||||
// },
|
// },
|
||||||
|
|
||||||
|
// Tournaments without awards get featured belows
|
||||||
|
{
|
||||||
|
title: 'SF 2022 Ballot',
|
||||||
|
blurb: 'Which ballot initiatives will pass this year in SF and CA?',
|
||||||
|
endTime: toDate('Nov 8, 2022'),
|
||||||
|
groupId: 'VkWZyS5yxs8XWUJrX9eq',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: '2024 Democratic Nominees',
|
||||||
|
blurb: 'How would different Democratic candidates fare in 2024?',
|
||||||
|
endTime: toDate('Nov 2, 2024'),
|
||||||
|
groupId: 'gFhjgFVrnYeFYfxhoLNn',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Private Tech Companies',
|
||||||
|
blurb: 'What will these companies exit for?',
|
||||||
|
endTime: toDate('Dec 31, 2030'),
|
||||||
|
groupId: 'faNUnphw6Eoq7OJBRJds',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
type SectionInfo = {
|
type SectionInfo = {
|
||||||
|
@ -135,20 +157,23 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) {
|
||||||
title="Tournaments"
|
title="Tournaments"
|
||||||
description="Win money by betting in forecasting touraments on current events, sports, science, and more"
|
description="Win money by betting in forecasting touraments on current events, sports, science, and more"
|
||||||
/>
|
/>
|
||||||
<Col className="mx-4 mt-4 gap-10 sm:mx-10 xl:w-[125%]">
|
<Col className="m-4 gap-10 sm:mx-10 sm:gap-24 xl:w-[125%]">
|
||||||
{sections.map(({ tourney, slug, numPeople }) => (
|
{sections.map(
|
||||||
<div key={slug}>
|
({ tourney, slug, numPeople }) =>
|
||||||
<SectionHeader
|
tourney.award && (
|
||||||
url={groupPath(slug)}
|
<div key={slug}>
|
||||||
title={tourney.title}
|
<SectionHeader
|
||||||
ppl={numPeople}
|
url={groupPath(slug, 'about')}
|
||||||
award={tourney.award}
|
title={tourney.title}
|
||||||
endTime={tourney.endTime}
|
ppl={numPeople}
|
||||||
/>
|
award={tourney.award}
|
||||||
<span>{tourney.blurb}</span>
|
endTime={tourney.endTime}
|
||||||
<MarketCarousel slug={slug} />
|
/>
|
||||||
</div>
|
<span className="text-gray-500">{tourney.blurb}</span>
|
||||||
))}
|
<MarketCarousel slug={slug} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
url={Salem.url}
|
url={Salem.url}
|
||||||
|
@ -156,9 +181,52 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) {
|
||||||
award={Salem.award}
|
award={Salem.award}
|
||||||
endTime={Salem.endTime}
|
endTime={Salem.endTime}
|
||||||
/>
|
/>
|
||||||
<span>{Salem.blurb}</span>
|
<span className="text-gray-500">{Salem.blurb}</span>
|
||||||
<ImageCarousel url={Salem.url} images={Salem.images} />
|
<ImageCarousel url={Salem.url} images={Salem.images} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Title break */}
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="w-full border-t border-gray-300" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<span className="bg-gray-50 px-3 text-lg font-medium text-gray-900">
|
||||||
|
Featured Groups
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sections.map(
|
||||||
|
({ tourney, slug, numPeople }) =>
|
||||||
|
!tourney.award && (
|
||||||
|
<div key={slug}>
|
||||||
|
<SectionHeader
|
||||||
|
url={groupPath(slug, 'about')}
|
||||||
|
title={tourney.title}
|
||||||
|
ppl={numPeople}
|
||||||
|
award={tourney.award}
|
||||||
|
endTime={tourney.endTime}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-500">{tourney.blurb}</span>
|
||||||
|
<MarketCarousel slug={slug} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="pb-10 italic text-gray-500">
|
||||||
|
We'd love to sponsor more tournaments and groups. Have an idea? Ping{' '}
|
||||||
|
<SiteLink
|
||||||
|
className="font-semibold"
|
||||||
|
href="https://discord.com/invite/eHQBNBqXuh"
|
||||||
|
>
|
||||||
|
Austin on Discord
|
||||||
|
</SiteLink>
|
||||||
|
!
|
||||||
|
</p>
|
||||||
</Col>
|
</Col>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
|
@ -175,9 +243,7 @@ const SectionHeader = (props: {
|
||||||
return (
|
return (
|
||||||
<Link href={url}>
|
<Link href={url}>
|
||||||
<a className="group mb-3 flex flex-wrap justify-between">
|
<a className="group mb-3 flex flex-wrap justify-between">
|
||||||
<h2 className="text-xl font-semibold group-hover:underline md:text-3xl">
|
<h2 className="text-xl group-hover:underline md:text-3xl">{title}</h2>
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6">
|
<Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6">
|
||||||
{!!award && <span className="flex items-center">🏆 {award}</span>}
|
{!!award && <span className="flex items-center">🏆 {award}</span>}
|
||||||
{!!ppl && (
|
{!!ppl && (
|
||||||
|
|
|
@ -64,6 +64,8 @@ function putIntoMapAndFetch(data) {
|
||||||
document.getElementById('guess-type').innerText = 'Finding Fantastic Beasts'
|
document.getElementById('guess-type').innerText = 'Finding Fantastic Beasts'
|
||||||
} else if (whichGuesser === 'basic') {
|
} else if (whichGuesser === 'basic') {
|
||||||
document.getElementById('guess-type').innerText = 'How Basic'
|
document.getElementById('guess-type').innerText = 'How Basic'
|
||||||
|
} else if (whichGuesser === 'commander') {
|
||||||
|
document.getElementById('guess-type').innerText = 'General Knowledge'
|
||||||
}
|
}
|
||||||
setUpNewGame()
|
setUpNewGame()
|
||||||
}
|
}
|
||||||
|
@ -156,8 +158,8 @@ function determineIfSkip(card) {
|
||||||
if (card.flavor_name) {
|
if (card.flavor_name) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// don't include racist cards
|
|
||||||
return card.content_warning
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function putIntoMap(data) {
|
function putIntoMap(data) {
|
||||||
|
|
|
@ -3,16 +3,16 @@ import requests
|
||||||
import json
|
import json
|
||||||
|
|
||||||
# add category name here
|
# add category name here
|
||||||
allCategories = ['counterspell', 'beast', 'burn'] #, 'terror', 'wrath']
|
allCategories = ['counterspell', 'beast', 'burn', 'commander', 'artist'] #, 'terror', 'wrath', 'zombie', 'artifact']
|
||||||
specialCategories = ['set', 'basic']
|
specialCategories = ['set', 'basic']
|
||||||
|
|
||||||
|
|
||||||
def generate_initial_query(category):
|
def generate_initial_query(category):
|
||||||
string_query = 'https://api.scryfall.com/cards/search?q='
|
string_query = 'https://api.scryfall.com/cards/search?q='
|
||||||
if category == 'counterspell':
|
if category == 'counterspell':
|
||||||
string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure'
|
string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure+not%3Adfc'
|
||||||
elif category == 'beast':
|
elif category == 'beast':
|
||||||
string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken'
|
string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken+not%3Adfc'
|
||||||
# elif category == 'terror':
|
# elif category == 'terror':
|
||||||
# string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \
|
# string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \
|
||||||
# '%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure'
|
# '%3Ainstant+or+t%3Asorcery%29+o%3Atarget+not%3Aadventure'
|
||||||
|
@ -22,11 +22,19 @@ def generate_initial_query(category):
|
||||||
string_query += '%28c>%3Dr+or+mana>%3Dr%29+%28o%3A%2Fdamage+to+them%2F+or+%28o%3Adeals+o%3Adamage+o%3A' \
|
string_query += '%28c>%3Dr+or+mana>%3Dr%29+%28o%3A%2Fdamage+to+them%2F+or+%28o%3Adeals+o%3Adamage+o%3A' \
|
||||||
'%2Fcontroller%28%5C.%7C+%29%2F%29+or+o%3A%2F~+deals+%28.%7C..%29+damage+to+%28any+target%7C' \
|
'%2Fcontroller%28%5C.%7C+%29%2F%29+or+o%3A%2F~+deals+%28.%7C..%29+damage+to+%28any+target%7C' \
|
||||||
'.*player%28%5C.%7C+or+planeswalker%29%7C.*opponent%28%5C.%7C+or+planeswalker%29%29%2F%29' \
|
'.*player%28%5C.%7C+or+planeswalker%29%7C.*opponent%28%5C.%7C+or+planeswalker%29%29%2F%29' \
|
||||||
'+%28type%3Ainstant+or+type%3Asorcery%29+not%3Aadventure'
|
'+%28type%3Ainstant+or+type%3Asorcery%29+not%3Aadventure+not%3Adfc'
|
||||||
|
elif category == 'commander':
|
||||||
|
string_query += 'is%3Acommander+%28not%3Adigital+-banned%3Acommander+or+is%3Adigital+legal%3Ahistoricbrawl+or+legal%3Acommander+or+legal%3Abrawl%29'
|
||||||
|
# elif category == 'zombie':
|
||||||
|
# string_query += '-type%3Alegendary+type%3Azombie+-type%3Atoken'
|
||||||
|
# elif category == 'artifact':
|
||||||
|
# string_query += 't%3Aartifact&order=released&dir=asc&unique=prints&page='
|
||||||
|
# elif category == 'artist':
|
||||||
|
# string_query+= 'a%3A"Wylie+Beckert"+or+a%3A“Ernanda+Souza”+or+a%3A"randy+gallegos"+or+a%3A“Amy+Weber”+or+a%3A“Dan+Frazier”+or+a%3A“Thomas+M.+Baxa”+or+a%3A“Phil+Foglio”+or+a%3A“DiTerlizzi”+or+a%3A"steve+argyle"+or+a%3A"Veronique+Meignaud"+or+a%3A"Magali+Villeneuve"+or+a%3A"Michael+Sutfin"+or+a%3A“Volkan+Baǵa”+or+a%3A“Franz+Vohwinkel”+or+a%3A"Nils+Hamm"+or+a%3A"Mark+Poole"+or+a%3A"Carl+Critchlow"+or+a%3A"rob+alexander"+or+a%3A"igor+kieryluk"+or+a%3A“Victor+Adame+Minguez”+or+a%3A"johannes+voss"+or+a%3A"Svetlin+Velinov"+or+a%3A"ron+spencer"+or+a%3A"rk+post"+or+a%3A"kev+walker"+or+a%3A"rebecca+guay"+or+a%3A"seb+mckinnon"+or+a%3A"pete+venters"+or+a%3A"greg+staples"+or+a%3A"Christopher+Moeller"+or+a%3A"christopher+rush"+or+a%3A"Mark+Tedin"'
|
||||||
# add category string query here
|
# add category string query here
|
||||||
string_query += '+-%28set%3Asld+%28%28cn>%3D231+cn<%3D233%29+or+%28cn>%3D321+cn<%3D324%29+or+%28cn>%3D185+cn' \
|
string_query += '+-%28set%3Asld+%28%28cn>%3D231+cn<%3D233%29+or+%28cn>%3D321+cn<%3D324%29+or+%28cn>%3D185+cn' \
|
||||||
'<%3D189%29+or+%28cn>%3D138+cn<%3D142%29+or+%28cn>%3D364+cn<%3D368%29+or+cn%3A669+or+cn%3A670%29' \
|
'<%3D189%29+or+%28cn>%3D138+cn<%3D142%29+or+%28cn>%3D364+cn<%3D368%29+or+cn%3A669+or+cn%3A670%29' \
|
||||||
'%29+-name%3A%2F%5EA-%2F+not%3Adfc+not%3Asplit+-set%3Acmb2+-set%3Acmb1+-set%3Aplist+-set%3Adbl' \
|
'%29+-name%3A%2F%5EA-%2F+not%3Asplit+-set%3Acmb2+-set%3Acmb1+-set%3Aplist+-st%3Amemorabilia' \
|
||||||
'+language%3Aenglish&order=released&dir=asc&unique=prints&page='
|
'+language%3Aenglish&order=released&dir=asc&unique=prints&page='
|
||||||
print(string_query)
|
print(string_query)
|
||||||
return string_query
|
return string_query
|
||||||
|
@ -89,22 +97,35 @@ def fetch_special(query):
|
||||||
|
|
||||||
|
|
||||||
def to_compact_write_form(smallJson, art_names, response, category):
|
def to_compact_write_form(smallJson, art_names, response, category):
|
||||||
fieldsInCard = ['name', 'image_uris', 'content_warning', 'flavor_name', 'reprint', 'frame_effects', 'digital',
|
fieldsInCard = ['name', 'image_uris', 'flavor_name', 'reprint', 'frame_effects', 'digital', 'set_type']
|
||||||
'set_type']
|
|
||||||
data = []
|
data = []
|
||||||
# write all fields needed in card
|
# write all fields needed in card
|
||||||
for card in response['data']:
|
for card in response['data']:
|
||||||
|
# do not include racist cards
|
||||||
|
if 'content_warning' in card and card['content_warning'] == True:
|
||||||
|
continue
|
||||||
# do not repeat art
|
# do not repeat art
|
||||||
if 'illustration_id' not in card or card['illustration_id'] in art_names:
|
if 'card_faces' in card:
|
||||||
|
card_face = card['card_faces'][0]
|
||||||
|
if 'illustration_id' not in card_face or card_face['illustration_id'] in art_names:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
art_names.add(card_face['illustration_id'])
|
||||||
|
elif 'illustration_id' not in card or card['illustration_id'] in art_names:
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
art_names.add(card['illustration_id'])
|
art_names.add(card['illustration_id'])
|
||||||
write_card = dict()
|
write_card = dict()
|
||||||
for field in fieldsInCard:
|
for field in fieldsInCard:
|
||||||
|
# if field == 'name' and category == 'artifact':
|
||||||
|
# write_card['name'] = card['released_at'].split('-')[0]
|
||||||
if field == 'name' and 'card_faces' in card:
|
if field == 'name' and 'card_faces' in card:
|
||||||
write_card['name'] = card['card_faces'][0]['name']
|
write_card['name'] = card['card_faces'][0]['name']
|
||||||
elif field == 'image_uris':
|
elif field == 'image_uris':
|
||||||
write_card['image_uris'] = write_image_uris(card['image_uris'])
|
if 'card_faces' in card and 'image_uris' in card['card_faces'][0]:
|
||||||
|
write_card['image_uris'] = write_image_uris(card['card_faces'][0]['image_uris'])
|
||||||
|
else:
|
||||||
|
write_card['image_uris'] = write_image_uris(card['image_uris'])
|
||||||
elif field in card:
|
elif field in card:
|
||||||
write_card[field] = card[field]
|
write_card[field] = card[field]
|
||||||
data.append(write_card)
|
data.append(write_card)
|
||||||
|
@ -115,6 +136,9 @@ def to_compact_write_form_special(smallJson, art_names, response, category):
|
||||||
data = []
|
data = []
|
||||||
# write all fields needed in card
|
# write all fields needed in card
|
||||||
for card in response['data']:
|
for card in response['data']:
|
||||||
|
# do not include racist cards
|
||||||
|
if 'content_warning' in card and card['content_warning'] == True:
|
||||||
|
continue
|
||||||
if category == 'basic':
|
if category == 'basic':
|
||||||
write_card = dict()
|
write_card = dict()
|
||||||
# do not repeat art
|
# do not repeat art
|
||||||
|
@ -152,9 +176,9 @@ def write_image_uris(card_image_uris):
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# for category in allCategories:
|
for category in allCategories:
|
||||||
# print(category)
|
print(category)
|
||||||
# fetch_and_write_all(category, generate_initial_query(category))
|
fetch_and_write_all(category, generate_initial_query(category))
|
||||||
for category in specialCategories:
|
for category in specialCategories:
|
||||||
print(category)
|
print(category)
|
||||||
fetch_and_write_all_special(category, generate_initial_special_query(category))
|
fetch_and_write_all_special(category, generate_initial_special_query(category))
|
||||||
|
|
|
@ -17,6 +17,14 @@
|
||||||
f.parentNode.insertBefore(j, f)
|
f.parentNode.insertBefore(j, f)
|
||||||
})(window, document, 'script', 'dataLayer', 'GTM-M3MBVGG')
|
})(window, document, 'script', 'dataLayer', 'GTM-M3MBVGG')
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
function updateSettingDefault(digital, un, original) {
|
||||||
|
window.console.log(digital, un, original)
|
||||||
|
document.getElementById('digital').checked = digital
|
||||||
|
document.getElementById('un').checked = un
|
||||||
|
document.getElementById('original').checked = original
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<!-- End Google Tag Manager -->
|
<!-- End Google Tag Manager -->
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
|
@ -105,6 +113,18 @@
|
||||||
list-style: none;
|
list-style: none;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.option-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding-left: 65px;
|
||||||
|
}
|
||||||
|
.level-badge {
|
||||||
|
display: block;
|
||||||
|
width: 65px;
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
@ -125,48 +145,115 @@
|
||||||
action="guess.html"
|
action="guess.html"
|
||||||
style="display: flex; flex-direction: column; align-items: center"
|
style="display: flex; flex-direction: column; align-items: center"
|
||||||
>
|
>
|
||||||
<input
|
<div class="option-row">
|
||||||
type="radio"
|
<input
|
||||||
id="counterspell"
|
type="radio"
|
||||||
name="whichguesser"
|
id="counterspell"
|
||||||
value="counterspell"
|
name="whichguesser"
|
||||||
checked
|
value="counterspell"
|
||||||
/>
|
onchange="updateSettingDefault(true, true, false)"
|
||||||
<label class="radio-label" for="counterspell">
|
checked
|
||||||
<img
|
|
||||||
class="thumbnail"
|
|
||||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/1/71cfcba5-1571-48b8-a3db-55dca135506e.jpg?1562843855"
|
|
||||||
/>
|
/>
|
||||||
<h3>Counterspell Guesser</h3></label
|
<label class="radio-label" for="counterspell">
|
||||||
><br />
|
<img
|
||||||
|
class="thumbnail"
|
||||||
<input type="radio" id="burn" name="whichguesser" value="burn" />
|
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/1/71cfcba5-1571-48b8-a3db-55dca135506e.jpg?1562843855"
|
||||||
<label class="radio-label" for="burn">
|
/>
|
||||||
|
<h3>Counterspell Guesser</h3></label
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
class="thumbnail"
|
class="level-badge"
|
||||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/0/60b2fae1-242b-45e0-a757-b1adc02c06f3.jpg?1562760596"
|
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/a/a8/Advanced_level.jpg"
|
||||||
/>
|
/>
|
||||||
<h3>Match With Hot Singles</h3></label
|
</div>
|
||||||
><br />
|
|
||||||
|
|
||||||
<input type="radio" id="beast" name="whichguesser" value="beast" />
|
|
||||||
<label class="radio-label" for="beast">
|
|
||||||
<img
|
|
||||||
class="thumbnail"
|
|
||||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/3/33f7e788-8fc7-49f3-804b-2d7f96852d4b.jpg?1562905469"
|
|
||||||
/>
|
|
||||||
<h3>Finding Fantastic Beasts</h3></label
|
|
||||||
>
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<input type="radio" id="basic" name="whichguesser" value="basic" />
|
<div class="option-row">
|
||||||
<label class="radio-label" for="basic">
|
<input
|
||||||
<img
|
type="radio"
|
||||||
class="thumbnail"
|
id="burn"
|
||||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/3/03683fbb-9843-4c14-bb95-387150e97c90.jpg?1642161346"
|
name="whichguesser"
|
||||||
|
value="burn"
|
||||||
|
onchange="updateSettingDefault(true, true, false)"
|
||||||
/>
|
/>
|
||||||
<h3>How Basic</h3></label
|
<label class="radio-label" for="burn">
|
||||||
>
|
<img
|
||||||
|
class="thumbnail"
|
||||||
|
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/0/60b2fae1-242b-45e0-a757-b1adc02c06f3.jpg?1562760596"
|
||||||
|
/>
|
||||||
|
<h3>Match With Hot Singles</h3></label
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="level-badge"
|
||||||
|
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/a/a8/Advanced_level.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<div class="option-row">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="beast"
|
||||||
|
name="whichguesser"
|
||||||
|
value="beast"
|
||||||
|
onchange="updateSettingDefault(true, true, false)"
|
||||||
|
/>
|
||||||
|
<label class="radio-label" for="beast">
|
||||||
|
<img
|
||||||
|
class="thumbnail"
|
||||||
|
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/3/33f7e788-8fc7-49f3-804b-2d7f96852d4b.jpg?1562905469"
|
||||||
|
/>
|
||||||
|
<h3>Finding Fantastic Beasts</h3></label
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="level-badge"
|
||||||
|
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/a/a8/Advanced_level.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<div class="option-row">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="basic"
|
||||||
|
name="whichguesser"
|
||||||
|
value="basic"
|
||||||
|
onchange="updateSettingDefault(true, true, true)"
|
||||||
|
/>
|
||||||
|
<label class="radio-label" for="basic">
|
||||||
|
<img
|
||||||
|
class="thumbnail"
|
||||||
|
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/e/5/e52ed647-bd30-40a5-b648-0b98d1a3fd4a.jpg?1562949575"
|
||||||
|
/>
|
||||||
|
<h3>How Basic</h3></label
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="level-badge"
|
||||||
|
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/a/af/Expert_level.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<div class="option-row">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="commander"
|
||||||
|
name="whichguesser"
|
||||||
|
value="commander"
|
||||||
|
onchange="updateSettingDefault(false, false, false)"
|
||||||
|
/>
|
||||||
|
<label class="radio-label" for="commander">
|
||||||
|
<img
|
||||||
|
class="thumbnail"
|
||||||
|
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/d/9/d9631cb2-d53b-4401-b53b-29d27bdefc44.jpg?1562770627"
|
||||||
|
/>
|
||||||
|
<h3>General Knowledge</h3></label
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="level-badge"
|
||||||
|
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/0/00/Starter_level.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<details id="addl-options">
|
<details id="addl-options">
|
||||||
|
|
1
web/public/mtg/jsons/artist.json
Normal file
1
web/public/mtg/jsons/artist.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
web/public/mtg/jsons/commander.json
Normal file
1
web/public/mtg/jsons/commander.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -60,6 +60,18 @@ module.exports = {
|
||||||
'overflow-wrap': 'anywhere',
|
'overflow-wrap': 'anywhere',
|
||||||
'word-break': 'break-word', // for Safari
|
'word-break': 'break-word', // for Safari
|
||||||
},
|
},
|
||||||
|
'.only-thumb': {
|
||||||
|
'pointer-events': 'none',
|
||||||
|
'&::-webkit-slider-thumb': {
|
||||||
|
'pointer-events': 'auto !important',
|
||||||
|
},
|
||||||
|
'&::-moz-range-thumb': {
|
||||||
|
'pointer-events': 'auto !important',
|
||||||
|
},
|
||||||
|
'&::-ms-thumb': {
|
||||||
|
'pointer-events': 'auto !important',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
Loading…
Reference in New Issue
Block a user