Merge remote-tracking branch 'remotes/origin/main' into twitch-linking

This commit is contained in:
Phil 2022-09-13 18:04:53 +01:00
commit 6a900205c0
93 changed files with 3691 additions and 2273 deletions

View File

@ -73,6 +73,7 @@ export const PROD_CONFIG: EnvConfig = {
'manticmarkets@gmail.com', // Manifold
'iansphilips@gmail.com', // Ian
'd4vidchee@gmail.com', // D4vid
'federicoruizcassarino@gmail.com', // Fede
],
visibility: 'PUBLIC',

View File

@ -12,7 +12,18 @@ export type Group = {
aboutPostId?: string
chatDisabled?: boolean
mostRecentContractAddedTime?: number
cachedLeaderboard?: {
topTraders: {
userId: string
score: number
}[]
topCreators: {
userId: string
score: number
}[]
}
}
export const MAX_GROUP_NAME_LENGTH = 75
export const MAX_ABOUT_LENGTH = 140
export const MAX_ID_LENGTH = 60

View File

@ -1,3 +1,6 @@
import { notification_subscription_types, PrivateUser } from './user'
import { DOMAIN } from './envs/constants'
export type Notification = {
id: string
userId: string
@ -51,28 +54,106 @@ export type notification_source_update_types =
| 'deleted'
| 'closed'
/* Optional - if possible use a keyof notification_subscription_types */
export type notification_reason_types =
| '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'
| 'you_follow_user'
| 'added_you_to_group'
| 'contract_from_followed_user'
| 'you_referred_user'
| 'user_joined_to_bet_on_your_market'
| 'unique_bettors_on_your_contract'
| 'on_group_you_are_member_of'
| 'tip_received'
| 'bet_fill'
| 'user_joined_from_your_group_invite'
| 'challenge_accepted'
| 'betting_streak_incremented'
| 'loan_income'
| 'you_follow_contract'
| 'liked_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&section=${subscriptionType}`,
}
}

View File

@ -13,7 +13,6 @@ import { addObjects } from './util/object'
export const getDpmCancelPayouts = (contract: DPMContract, bets: Bet[]) => {
const { pool } = contract
const poolTotal = sum(Object.values(pool))
console.log('resolved N/A, pool M$', poolTotal)
const betSum = sumBy(bets, (b) => b.amount)
@ -58,17 +57,6 @@ export const getDpmStandardPayouts = (
liquidityFee: 0,
})
console.log(
'resolved',
outcome,
'pool',
poolTotal,
'profits',
profits,
'creator fee',
creatorFee
)
return {
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
creatorPayout: creatorFee,
@ -110,17 +98,6 @@ export const getNumericDpmPayouts = (
liquidityFee: 0,
})
console.log(
'resolved numeric bucket: ',
outcome,
'pool',
poolTotal,
'profits',
profits,
'creator fee',
creatorFee
)
return {
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
creatorPayout: creatorFee,
@ -163,17 +140,6 @@ export const getDpmMktPayouts = (
liquidityFee: 0,
})
console.log(
'resolved MKT',
p,
'pool',
pool,
'profits',
profits,
'creator fee',
creatorFee
)
return {
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
creatorPayout: creatorFee,
@ -216,16 +182,6 @@ export const getPayoutsMultiOutcome = (
liquidityFee: 0,
})
console.log(
'resolved',
resolutions,
'pool',
poolTotal,
'profits',
profits,
'creator fee',
creatorFee
)
return {
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
creatorPayout: creatorFee,

View File

@ -1,4 +1,3 @@
import { sum } from 'lodash'
import { Bet } from './bet'
import { getProbability } from './calculate'
@ -43,18 +42,6 @@ export const getStandardFixedPayouts = (
const { collectedFees } = contract
const creatorPayout = collectedFees.creatorFee
console.log(
'resolved',
outcome,
'pool',
contract.pool[outcome],
'payouts',
sum(payouts),
'creator fee',
creatorPayout
)
const liquidityPayouts = getLiquidityPoolPayouts(
contract,
outcome,
@ -98,18 +85,6 @@ export const getMktFixedPayouts = (
const { collectedFees } = contract
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)
return { payouts, creatorPayout, liquidityPayouts, collectedFees }

View File

@ -13,7 +13,10 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = sumBy(noBets, (b) => b.shares)
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 loanPayment = loanAmount * soldFrac
const netAmount = shares - loanPayment

View File

@ -1,8 +1,8 @@
import { groupBy, sumBy, mapValues, partition } from 'lodash'
import { groupBy, sumBy, mapValues } from 'lodash'
import { Bet } from './bet'
import { getContractBetMetrics } from './calculate'
import { Contract } from './contract'
import { getPayouts } from './payouts'
export function scoreCreators(contracts: Contract[]) {
const creatorScore = mapValues(
@ -30,46 +30,8 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
}
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
const { resolution } = contract
const resolutionProb =
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
const betsByUser = groupBy(bets, bet => bet.userId)
return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit)
}
export function addUserScores(

View File

@ -1,3 +1,5 @@
import { filterDefined } from './util/array'
export type User = {
id: string
createdTime: number
@ -34,7 +36,7 @@ export type User = {
followerCountCached: number
followedCategories?: string[]
homeSections?: { visible: string[]; hidden: string[] }
homeSections?: string[]
referredByUserId?: string
referredByContractId?: string
@ -63,7 +65,9 @@ export type PrivateUser = {
initialDeviceToken?: string
initialIpAddress?: string
apiKey?: string
/** @deprecated - use notificationSubscriptionTypes */
notificationPreferences?: notification_subscribe_types
notificationSubscriptionTypes: notification_subscription_types
twitchInfo?: {
twitchName: 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 PortfolioMetrics = {
@ -83,3 +136,140 @@ export type PortfolioMetrics = {
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
export const getDefaultNotificationSettings = (
userId: string,
privateUser?: PrivateUser,
noEmails?: boolean
) => {
const prevPref = privateUser?.notificationPreferences ?? 'all'
const wantsLess = prevPref === 'less'
const wantsAll = prevPref === 'all'
const {
unsubscribedFromCommentEmails,
unsubscribedFromAnswerEmails,
unsubscribedFromResolutionEmails,
unsubscribedFromWeeklyTrendingEmails,
unsubscribedFromGenericEmails,
} = privateUser || {}
const constructPref = (browserIf: boolean, emailIf: boolean) => {
const browser = browserIf ? 'browser' : undefined
const email = noEmails ? undefined : emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_destination_types[]
}
return {
// Watched Markets
all_comments_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromCommentEmails
),
all_answers_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromAnswerEmails
),
// Comments
tips_on_your_comments: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
comments_by_followed_users_on_watched_markets: constructPref(
wantsAll,
false
),
all_replies_to_my_comments_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
all_replies_to_my_answers_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromCommentEmails
),
// Answers
answers_by_followed_users_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromAnswerEmails
),
answers_by_market_creator_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromAnswerEmails
),
all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref(
wantsAll,
!unsubscribedFromAnswerEmails
),
// On users' markets
your_contract_closed: constructPref(
wantsAll || wantsLess,
!unsubscribedFromResolutionEmails
), // High priority
all_comments_on_my_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
all_answers_on_my_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromAnswerEmails
),
subsidized_your_market: constructPref(wantsAll || wantsLess, true),
// Market updates
resolutions_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromResolutionEmails
),
market_updates_on_watched_markets: constructPref(
wantsAll || wantsLess,
false
),
market_updates_on_watched_markets_with_shares_in: constructPref(
wantsAll || wantsLess,
false
),
resolutions_on_watched_markets_with_shares_in: constructPref(
wantsAll || wantsLess,
!unsubscribedFromResolutionEmails
),
//Balance Changes
loan_income: constructPref(wantsAll || wantsLess, false),
betting_streaks: constructPref(wantsAll || wantsLess, false),
referral_bonuses: constructPref(wantsAll || wantsLess, true),
unique_bettors_on_your_contract: constructPref(
wantsAll || wantsLess,
false
),
tipped_comments_on_watched_markets: constructPref(
wantsAll || wantsLess,
!unsubscribedFromCommentEmails
),
tips_on_your_markets: constructPref(wantsAll || wantsLess, true),
limit_order_fills: constructPref(wantsAll || wantsLess, false),
// General
tagged_user: constructPref(wantsAll || wantsLess, true),
on_new_follow: constructPref(wantsAll || wantsLess, true),
contract_from_followed_user: constructPref(wantsAll || wantsLess, true),
trending_markets: constructPref(
false,
!unsubscribedFromWeeklyTrendingEmails
),
profit_loss_updates: constructPref(false, true),
probability_updates_on_watched_markets: constructPref(
wantsAll || wantsLess,
false
),
thank_you_for_purchases: constructPref(
false,
!unsubscribedFromGenericEmails
),
onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails),
} as notification_subscription_types
}

View File

@ -23,8 +23,15 @@ import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type'
import { find } from 'linkifyjs'
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) {
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
const matches = (text.match(regex) || []).map((match) =>

View File

@ -60,23 +60,27 @@ Parameters:
Requires no authorization.
### `GET /v0/groups/[slug]`
### `GET /v0/group/[slug]`
Gets a group by its slug.
Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]`
Gets a group by its unique ID.
Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]/markets`
Gets a group's markets by its unique ID.
Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/markets`

View File

@ -2,10 +2,30 @@
"functions": {
"predeploy": "cd functions && yarn build",
"runtime": "nodejs16",
"source": "functions/dist"
"source": "functions/dist",
"ignore": [
"node_modules",
".git",
"firebase-debug.log",
"firebase-debug.*.log"
]
},
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"emulators": {
"functions": {
"port": 5001
},
"firestore": {
"port": 8080
},
"pubsub": {
"port": 8085
},
"ui": {
"enabled": true
}
}
}

View File

@ -77,7 +77,7 @@ service cloud.firestore {
allow read: if userId == request.auth.uid || isAdmin();
allow update: if (userId == request.auth.uid || isAdmin())
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['apiKey', 'twitchInfo', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]);
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails','notificationSubscriptionTypes' ]);
}
match /private-users/{userId}/views/{viewId} {
@ -170,7 +170,7 @@ service cloud.firestore {
allow read;
}
match /groups/{groupId} {
match /groups/{groupId} {
allow read;
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data)
@ -184,7 +184,7 @@ service cloud.firestore {
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 delete: if request.auth.uid == resource.data.userId;
allow delete: if request.auth.uid == resource.data.userId;
}
function isGroupMember() {

View File

@ -17,4 +17,5 @@ package-lock.json
ui-debug.log
firebase-debug.log
firestore-debug.log
pubsub-debug.log
firestore_export/

View File

@ -37,6 +37,45 @@ export const changeUser = async (
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) => {
if (update.username) {
update.username = cleanUsername(update.username)
@ -58,42 +97,7 @@ export const changeUser = async (
const userRef = firestore.collection('users').doc(user.id)
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)
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))
})
}

View File

@ -5,8 +5,7 @@ import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import { getNewMultiBetInfo } from '../../common/new-bet'
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
import { getContract, getValues } from './utils'
import { sendNewAnswerEmail } from './emails'
import { getValues } from './utils'
import { APIError, newEndpoint, validate } from './api'
const bodySchema = z.object({
@ -97,10 +96,6 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
return answer
})
const contract = await getContract(contractId)
if (answer && contract) await sendNewAnswerEmail(answer, contract)
return answer
})

View File

@ -10,7 +10,7 @@ import {
MAX_GROUP_NAME_LENGTH,
MAX_ID_LENGTH,
} from '../../common/group'
import { APIError, newEndpoint, validate } from '../../functions/src/api'
import { APIError, newEndpoint, validate } from './api'
import { z } from 'zod'
const bodySchema = z.object({

View File

@ -1,13 +1,12 @@
import * as admin from 'firebase-admin'
import {
getDestinationsForUser,
Notification,
notification_reason_types,
notification_source_update_types,
notification_source_types,
} from '../../common/notification'
import { User } from '../../common/user'
import { Contract } from '../../common/contract'
import { getValues, log } from './utils'
import { getPrivateUser, getValues } from './utils'
import { Comment } from '../../common/comment'
import { uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
@ -15,20 +14,27 @@ import { Answer } from '../../common/answer'
import { getContractBetMetrics } from '../../common/calculate'
import { removeUndefinedProps } from '../../common/util/object'
import { TipTxn } from '../../common/txn'
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
import { Group } from '../../common/group'
import { Challenge } from '../../common/challenge'
import { richTextToString } from '../../common/util/parse'
import { Like } from '../../common/like'
import {
sendMarketCloseEmail,
sendMarketResolutionEmail,
sendNewAnswerEmail,
sendNewCommentEmail,
sendNewFollowedMarketEmail,
} from './emails'
import { filterDefined } from '../../common/util/array'
const firestore = admin.firestore()
type user_to_reason_texts = {
type recipients_to_reason_texts = {
[userId: string]: { reason: notification_reason_types }
}
export const createNotification = async (
sourceId: string,
sourceType: notification_source_types,
sourceUpdateType: notification_source_update_types,
sourceType: 'contract' | 'liquidity' | 'follow',
sourceUpdateType: 'closed' | 'created',
sourceUser: User,
idempotencyKey: string,
sourceText: string,
@ -41,9 +47,9 @@ export const createNotification = async (
) => {
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
const shouldGetNotification = (
const shouldReceiveNotification = (
userId: string,
userToReasonTexts: user_to_reason_texts
userToReasonTexts: recipients_to_reason_texts
) => {
return (
sourceUser.id != userId &&
@ -51,18 +57,25 @@ export const createNotification = async (
)
}
const createUsersNotifications = async (
userToReasonTexts: user_to_reason_texts
const sendNotificationsIfSettingsPermit = async (
userToReasonTexts: recipients_to_reason_texts
) => {
await Promise.all(
Object.keys(userToReasonTexts).map(async (userId) => {
for (const userId in userToReasonTexts) {
const { reason } = userToReasonTexts[userId]
const privateUser = await getPrivateUser(userId)
if (!privateUser) continue
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
privateUser,
reason
)
if (sendToBrowser) {
const notificationRef = firestore
.collection(`/users/${userId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId,
reason: userToReasonTexts[userId].reason,
reason,
createdTime: Date.now(),
isSeen: false,
sourceId,
@ -80,212 +93,232 @@ export const createNotification = async (
sourceTitle: title ? title : sourceContract?.question,
}
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 = (
userToReasonTexts: user_to_reason_texts,
followedUserId: string
) => {
if (shouldGetNotification(followedUserId, userToReasonTexts))
userToReasonTexts[followedUserId] = {
reason: 'on_new_follow',
if (!sendToEmail) continue
if (reason === 'your_contract_closed' && privateUser && sourceContract) {
// TODO: include number and names of bettors waiting for creator to resolve their market
await sendMarketCloseEmail(
reason,
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.
const userToReasonTexts: recipients_to_reason_texts = {}
if (sourceType === 'follow' && recipients?.[0]) {
notifyFollowedUser(userToReasonTexts, recipients[0])
} else if (
sourceType === 'group' &&
sourceUpdateType === 'created' &&
recipients
) {
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'created' &&
sourceContract
) {
await notifyUsersFollowers(userToReasonTexts)
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
if (shouldReceiveNotification(recipients[0], userToReasonTexts))
userToReasonTexts[recipients[0]] = {
reason: 'on_new_follow',
}
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'closed' &&
sourceContract
) {
await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true,
})
userToReasonTexts[sourceContract.creatorId] = {
reason: 'your_contract_closed',
}
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (
sourceType === 'liquidity' &&
sourceUpdateType === 'created' &&
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 (
sourceId: string,
sourceType: notification_source_types,
sourceUpdateType: notification_source_update_types,
sourceType: 'comment' | 'answer' | 'contract',
sourceUpdateType: 'created' | 'updated' | 'resolved',
sourceUser: User,
idempotencyKey: string,
sourceText: string,
sourceContract: Contract,
miscData?: {
relatedSourceType?: notification_source_types
repliedUserId?: string
taggedUserIds?: string[]
repliedUsersInfo: replied_users_info
taggedUserIds: string[]
},
resolutionData?: {
bets: Bet[]
userInvestments: { [userId: string]: number }
userPayouts: { [userId: string]: number }
creator: User
creatorPayout: number
contract: Contract
outcome: string
resolutionProbability?: number
resolutions?: { [outcome: string]: number }
}
) => {
const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {}
const { repliedUsersInfo, taggedUserIds } = miscData ?? {}
const createUsersNotifications = async (
userToReasonTexts: user_to_reason_texts
) => {
await Promise.all(
Object.keys(userToReasonTexts).map(async (userId) => {
const notificationRef = firestore
.collection(`/users/${userId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId,
reason: userToReasonTexts[userId].reason,
createdTime: Date.now(),
isSeen: false,
sourceId,
sourceType,
sourceUpdateType,
sourceContractId: sourceContract.id,
sourceUserName: sourceUser.name,
sourceUserUsername: sourceUser.username,
sourceUserAvatarUrl: sourceUser.avatarUrl,
sourceText,
sourceContractCreatorUsername: sourceContract.creatorUsername,
sourceContractTitle: sourceContract.question,
sourceContractSlug: sourceContract.slug,
sourceSlug: sourceContract.slug,
sourceTitle: sourceContract.question,
}
await notificationRef.set(removeUndefinedProps(notification))
})
)
}
const browserRecipientIdsList: string[] = []
const emailRecipientIdsList: string[] = []
// get contract follower documents and check here if they're a follower
const contractFollowersSnap = await firestore
.collection(`contracts/${sourceContract.id}/follows`)
.get()
const contractFollowersIds = contractFollowersSnap.docs.map(
(doc) => doc.data().id
)
log('contractFollowerIds', contractFollowersIds)
const 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) => {
return contractFollowersIds.includes(userId)
}
const shouldGetNotification = (
const sendNotificationsIfSettingsPermit = async (
userId: string,
userToReasonTexts: user_to_reason_texts
reason: notification_reason_types
) => {
return (
sourceUser.id != userId &&
!Object.keys(userToReasonTexts).includes(userId)
if (
!stillFollowingContract(sourceContract.creatorId) ||
sourceUser.id == userId
)
return
const privateUser = await getPrivateUser(userId)
if (!privateUser) return
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
privateUser,
reason
)
}
const notifyContractFollowers = async (
userToReasonTexts: user_to_reason_texts
) => {
for (const userId of contractFollowersIds) {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'you_follow_contract',
}
// Browser notifications
if (sendToBrowser && !browserRecipientIdsList.includes(userId)) {
await createBrowserNotification(userId, reason)
browserRecipientIdsList.push(userId)
}
// 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 (
userToReasonTexts: user_to_reason_texts
) => {
if (
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) &&
stillFollowingContract(sourceContract.creatorId)
)
userToReasonTexts[sourceContract.creatorId] = {
reason: 'on_users_contract',
}
const notifyContractFollowers = async () => {
for (const userId of contractFollowersIds) {
await sendNotificationsIfSettingsPermit(
userId,
sourceType === 'answer'
? 'answer_on_contract_you_follow'
: sourceType === 'comment'
? 'comment_on_contract_you_follow'
: sourceUpdateType === 'updated'
? 'update_on_contract_you_follow'
: 'resolution_on_contract_you_follow'
)
}
}
const notifyOtherAnswerersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const notifyContractCreator = async () => {
await sendNotificationsIfSettingsPermit(
sourceContract.creatorId,
sourceType === 'comment'
? 'comment_on_your_contract'
: 'answer_on_your_contract'
)
}
const notifyOtherAnswerersOnContract = async () => {
const answers = await getValues<Answer>(
firestore
.collection('contracts')
@ -293,20 +326,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
.collection('answers')
)
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
await Promise.all(
recipientUserIds.map((userId) =>
sendNotificationsIfSettingsPermit(
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 (
userToReasonTexts: user_to_reason_texts
) => {
const notifyOtherCommentersOnContract = async () => {
const comments = await getValues<Comment>(
firestore
.collection('contracts')
@ -314,20 +350,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
.collection('comments')
)
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
await Promise.all(
recipientUserIds.map((userId) =>
sendNotificationsIfSettingsPermit(
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 (
userToReasonTexts: user_to_reason_texts
) => {
const notifyBettorsOnContract = async () => {
const betsSnap = await firestore
.collection(`contracts/${sourceContract.id}/bets`)
.get()
@ -343,88 +382,77 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
)
}
)
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
await Promise.all(
recipientUserIds.map((userId) =>
sendNotificationsIfSettingsPermit(
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 = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string,
relatedSourceType: notification_source_types
) => {
if (
shouldGetNotification(relatedUserId, userToReasonTexts) &&
stillFollowingContract(relatedUserId)
) {
if (relatedSourceType === 'comment') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_comment',
}
} else if (relatedSourceType === 'answer') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_answer',
}
}
}
const notifyRepliedUser = async () => {
if (sourceType === 'comment' && repliedUsersInfo)
await Promise.all(
Object.keys(repliedUsersInfo).map((userId) =>
sendNotificationsIfSettingsPermit(
userId,
repliedUsersInfo[userId].repliedToType === 'answer'
? 'reply_to_users_answer'
: 'reply_to_users_comment'
)
)
)
}
const notifyTaggedUsers = (
userToReasonTexts: user_to_reason_texts,
userIds: (string | undefined)[]
) => {
userIds.forEach((id) => {
console.log('tagged user: ', id)
// Allowing non-following users to get tagged
if (id && shouldGetNotification(id, userToReasonTexts))
userToReasonTexts[id] = {
reason: 'tagged_user',
}
})
const notifyTaggedUsers = async () => {
if (sourceType === 'comment' && taggedUserIds && taggedUserIds.length > 0)
await Promise.all(
taggedUserIds.map((userId) =>
sendNotificationsIfSettingsPermit(userId, 'tagged_user')
)
)
}
const notifyLiquidityProviders = async (
userToReasonTexts: user_to_reason_texts
) => {
const notifyLiquidityProviders = async () => {
const liquidityProviders = await firestore
.collection(`contracts/${sourceContract.id}/liquidity`)
.get()
const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId)
)
liquidityProvidersIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
) {
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
}
})
await Promise.all(
liquidityProvidersIds.map((userId) =>
sendNotificationsIfSettingsPermit(
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'
)
)
)
}
const userToReasonTexts: user_to_reason_texts = {}
if (sourceType === 'comment') {
if (repliedUserId && relatedSourceType)
notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType)
if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? [])
}
await notifyContractCreator(userToReasonTexts)
await notifyOtherAnswerersOnContract(userToReasonTexts)
await notifyLiquidityProviders(userToReasonTexts)
await notifyBettorsOnContract(userToReasonTexts)
await notifyOtherCommentersOnContract(userToReasonTexts)
// if they weren't added previously, add them now
await notifyContractFollowers(userToReasonTexts)
await createUsersNotifications(userToReasonTexts)
await notifyRepliedUser()
await notifyTaggedUsers()
await notifyContractCreator()
await notifyOtherAnswerersOnContract()
await notifyLiquidityProviders()
await notifyBettorsOnContract()
await notifyOtherCommentersOnContract()
// if they weren't notified previously, notify them now
await notifyContractFollowers()
}
export const createTipNotification = async (
@ -436,8 +464,15 @@ export const createTipNotification = async (
contract?: Contract,
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
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
@ -461,6 +496,9 @@ export const createTipNotification = async (
sourceTitle: group?.name,
}
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 (
@ -471,6 +509,14 @@ export const createBetFillNotification = async (
contract: Contract,
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 fillAmount = fill?.amount ?? 0
@ -496,38 +542,8 @@ export const createBetFillNotification = async (
sourceContractId: contract.id,
}
return await notificationRef.set(removeUndefinedProps(notification))
}
export const createGroupCommentNotification = async (
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))
// maybe TODO: send email notification to bet creator
}
export const createReferralNotification = async (
@ -538,6 +554,14 @@ export const createReferralNotification = async (
referredByContract?: Contract,
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
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
@ -575,6 +599,8 @@ export const createReferralNotification = async (
: referredByContract?.question,
}
await notificationRef.set(removeUndefinedProps(notification))
// TODO send email notification
}
export const createLoanIncomeNotification = async (
@ -582,6 +608,14 @@ export const createLoanIncomeNotification = async (
idempotencyKey: string,
income: number
) => {
const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
privateUser,
'loan_income'
)
if (!sendToBrowser) return
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
@ -612,6 +646,14 @@ export const createChallengeAcceptedNotification = async (
acceptedAmount: number,
contract: Contract
) => {
const privateUser = await getPrivateUser(challengeCreator.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
privateUser,
'challenge_accepted'
)
if (!sendToBrowser) return
const notificationRef = firestore
.collection(`/users/${challengeCreator.id}/notifications`)
.doc()
@ -645,6 +687,14 @@ export const createBettingStreakBonusNotification = async (
amount: number,
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
.collection(`/users/${user.id}/notifications`)
.doc(idempotencyKey)
@ -680,13 +730,24 @@ export const createLikeNotification = async (
contract: Contract,
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
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
reason: tip ? 'liked_and_tipped_your_contract' : 'liked_your_contract',
reason: 'liked_and_tipped_your_contract',
createdTime: Date.now(),
isSeen: false,
sourceId: like.id,
@ -703,20 +764,8 @@ export const createLikeNotification = async (
sourceTitle: contract.question,
}
return await notificationRef.set(removeUndefinedProps(notification))
}
export async function filterUserIdsForOnlyFollowerIds(
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))
// TODO send email notification
}
export const createUniqueBettorBonusNotification = async (
@ -727,6 +776,15 @@ export const createUniqueBettorBonusNotification = async (
amount: number,
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
.collection(`/users/${contractCreatorId}/notifications`)
.doc(idempotencyKey)
@ -752,4 +810,82 @@ export const createUniqueBettorBonusNotification = async (
sourceContractCreatorUsername: contract.creatorUsername,
}
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')
}
}

View File

@ -1,7 +1,11 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { PrivateUser, User } from '../../common/user'
import {
getDefaultNotificationSettings,
PrivateUser,
User,
} from '../../common/user'
import { getUser, getUserByUsername, getValues } from './utils'
import { randomString } from '../../common/util/random'
import {
@ -79,6 +83,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
email,
initialIpAddress: req.ip,
initialDeviceToken: deviceToken,
notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid),
}
await firestore.collection('private-users').doc(auth.uid).create(privateUser)

View File

@ -284,9 +284,12 @@
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
<p style="margin: 10px 0;">This e-mail has been sent to {{name}}, <a
href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;"
target="_blank">click here to unsubscribe</a>.</p>
<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>

View File

@ -186,8 +186,9 @@
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
">Did you know you create your own prediction market on <a class="link-build-content"
style="color: #55575d" target="_blank" href="https://manifold.markets">Manifold</a> for
">Did you know you can create your own prediction market on <a
class="link-build-content" style="color: #55575d" target="_blank"
href="https://manifold.markets">Manifold</a> on
any question you care about?</span>
</p>
@ -490,10 +491,10 @@
">
<p style="margin: 10px 0">
This e-mail has been sent to {{name}},
<a href="{{unsubscribeLink}}" style="
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe</a>.
" target="_blank">click here to manage your notifications</a>.
</p>
</div>
</td>

View File

@ -440,11 +440,10 @@
<p style="margin: 10px 0">
This e-mail has been sent to
{{name}},
<a href="{{unsubscribeLink}}"
style="
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe</a> from future recommended markets.
" target="_blank">click here to manage your notifications</a>.
</p>
</div>
</td>

View File

@ -526,19 +526,10 @@
"
>our Discord</a
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</td>
</tr>
</table>

View File

@ -367,14 +367,9 @@
margin: 0;
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
">unsubscribe</a>.
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</td>
</tr>
</table>

View File

@ -485,14 +485,9 @@
margin: 0;
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
">unsubscribe</a>.
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</td>
</tr>
</table>

View File

@ -367,14 +367,9 @@
margin: 0;
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
">unsubscribe</a>.
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</td>
</tr>
</table>

View 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>

View File

@ -500,14 +500,9 @@
margin: 0;
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 12px;
color: #999;
text-decoration: underline;
margin: 0;
">unsubscribe</a>.
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</td>
</tr>
</table>

View 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>

View File

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

View File

@ -214,10 +214,12 @@
<div
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
<p style="margin: 10px 0;">This e-mail has been sent
to {{name}}, <a href="{{unsubscribeLink}}"
style="color:inherit;text-decoration:none;"
target="_blank">click here to
unsubscribe</a>.</p>
to {{name}},
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to manage your notifications</a>.
</p>
</div>
</td>
</tr>

View File

@ -137,7 +137,7 @@
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;">
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>
</div>
</td>
@ -286,9 +286,12 @@
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
<p style="margin: 10px 0;">This e-mail has been sent to {{name}}, <a
href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;"
target="_blank">click here to unsubscribe</a>.</p>
<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>

View File

@ -1,10 +1,12 @@
import { DOMAIN } from '../../common/envs/constants'
import { Answer } from '../../common/answer'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { Comment } from '../../common/comment'
import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user'
import {
notification_subscription_types,
PrivateUser,
User,
} from '../../common/user'
import {
formatLargeNumber,
formatMoney,
@ -14,15 +16,16 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email'
import { getPrivateUser, getUser } from './utils'
import { getFunctionUrl } from '../../common/api'
import { richTextToString } from '../../common/util/parse'
import { getUser } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
import {
notification_reason_types,
getDestinationsForUser,
} from '../../common/notification'
export const sendMarketResolutionEmail = async (
userId: string,
reason: notification_reason_types,
privateUser: PrivateUser,
investment: number,
payout: number,
creator: User,
@ -32,15 +35,11 @@ export const sendMarketResolutionEmail = async (
resolutionProbability?: number,
resolutions?: { [outcome: string]: number }
) => {
const privateUser = await getPrivateUser(userId)
if (
!privateUser ||
privateUser.unsubscribedFromResolutionEmails ||
!privateUser.email
)
return
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
await getDestinationsForUser(privateUser, reason)
if (!privateUser || !privateUser.email || !sendToEmail) return
const user = await getUser(userId)
const user = await getUser(privateUser.id)
if (!user) return
const outcome = toDisplayResolution(
@ -53,17 +52,13 @@ export const sendMarketResolutionEmail = async (
const subject = `Resolved ${outcome}: ${contract.question}`
const creatorPayoutText =
creatorPayout >= 1 && userId === creator.id
creatorPayout >= 1 && privateUser.id === creator.id
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
: ''
const emailType = 'market-resolved'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const displayedInvestment =
Number.isNaN(investment) || investment < 0
? formatMoney(0)
: formatMoney(investment)
const correctedInvestment =
Number.isNaN(investment) || investment < 0 ? 0 : investment
const displayedInvestment = formatMoney(correctedInvestment)
const displayedPayout = formatMoney(payout)
@ -85,7 +80,7 @@ export const sendMarketResolutionEmail = async (
return await sendTemplateEmail(
privateUser.email,
subject,
'market-resolved',
correctedInvestment === 0 ? 'market-resolved-no-bets' : 'market-resolved',
templateData
)
}
@ -154,11 +149,12 @@ export const sendWelcomeEmail = async (
) => {
if (!privateUser || !privateUser.email) return
const { name, id: userId } = user
const { name } = user
const firstName = name.split(' ')[0]
const emailType = 'generic'
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'onboarding_flow' as keyof notification_subscription_types
}`
return await sendTemplateEmail(
privateUser.email,
@ -166,7 +162,7 @@ export const sendWelcomeEmail = async (
'welcome',
{
name: firstName,
unsubscribeLink,
unsubscribeUrl,
},
{
from: 'David from Manifold <david@manifold.markets>',
@ -217,23 +213,23 @@ export const sendOneWeekBonusEmail = async (
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromGenericEmails
!privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email')
)
return
const { name, id: userId } = user
const { name } = user
const firstName = name.split(' ')[0]
const emailType = 'generic'
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'onboarding_flow' as keyof notification_subscription_types
}`
return await sendTemplateEmail(
privateUser.email,
'Manifold Markets one week anniversary gift',
'one-week',
{
name: firstName,
unsubscribeLink,
unsubscribeUrl,
manalink: 'https://manifold.markets/link/lj4JbBvE',
},
{
@ -250,23 +246,23 @@ export const sendCreatorGuideEmail = async (
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromGenericEmails
!privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email')
)
return
const { name, id: userId } = user
const { name } = user
const firstName = name.split(' ')[0]
const emailType = 'generic'
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'onboarding_flow' as keyof notification_subscription_types
}`
return await sendTemplateEmail(
privateUser.email,
'Create your own prediction market',
'creating-market',
{
name: firstName,
unsubscribeLink,
unsubscribeUrl,
},
{
from: 'David from Manifold <david@manifold.markets>',
@ -282,15 +278,18 @@ export const sendThankYouEmail = async (
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromGenericEmails
!privateUser.notificationSubscriptionTypes.thank_you_for_purchases.includes(
'email'
)
)
return
const { name, id: userId } = user
const { name } = user
const firstName = name.split(' ')[0]
const emailType = 'generic'
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'thank_you_for_purchases' as keyof notification_subscription_types
}`
return await sendTemplateEmail(
privateUser.email,
@ -298,7 +297,7 @@ export const sendThankYouEmail = async (
'thank-you',
{
name: firstName,
unsubscribeLink,
unsubscribeUrl,
},
{
from: 'David from Manifold <david@manifold.markets>',
@ -307,16 +306,15 @@ export const sendThankYouEmail = async (
}
export const sendMarketCloseEmail = async (
reason: notification_reason_types,
user: User,
privateUser: PrivateUser,
contract: Contract
) => {
if (
!privateUser ||
privateUser.unsubscribedFromResolutionEmails ||
!privateUser.email
)
return
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
await getDestinationsForUser(privateUser, reason)
if (!privateUser.email || !sendToEmail) return
const { username, name, id: userId } = user
const firstName = name.split(' ')[0]
@ -324,8 +322,6 @@ export const sendMarketCloseEmail = async (
const { question, slug, volume } = contract
const url = `https://${DOMAIN}/${username}/${slug}`
const emailType = 'market-resolve'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
return await sendTemplateEmail(
privateUser.email,
@ -343,30 +339,24 @@ export const sendMarketCloseEmail = async (
}
export const sendNewCommentEmail = async (
userId: string,
reason: notification_reason_types,
privateUser: PrivateUser,
commentCreator: User,
contract: Contract,
comment: Comment,
commentText: string,
commentId: string,
bet?: Bet,
answerText?: string,
answerId?: string
) => {
const privateUser = await getPrivateUser(userId)
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromCommentEmails
)
return
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
await getDestinationsForUser(privateUser, reason)
if (!privateUser || !privateUser.email || !sendToEmail) return
const { question, creatorUsername, slug } = contract
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}`
const emailType = 'market-comment'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const { question } = contract
const marketUrl = `https://${DOMAIN}/${contract.creatorUsername}/${contract.slug}#${commentId}`
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
const { content } = comment
const text = richTextToString(content)
let betDescription = ''
if (bet) {
@ -380,7 +370,7 @@ export const sendNewCommentEmail = async (
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
const answerNumber = `#${answerId}`
const answerNumber = answerId ? `#${answerId}` : ''
return await sendTemplateEmail(
privateUser.email,
@ -391,7 +381,7 @@ export const sendNewCommentEmail = async (
answerNumber,
commentorName,
commentorAvatarUrl: commentorAvatarUrl ?? '',
comment: text,
comment: commentText,
marketUrl,
unsubscribeUrl,
betDescription,
@ -412,7 +402,7 @@ export const sendNewCommentEmail = async (
{
commentorName,
commentorAvatarUrl: commentorAvatarUrl ?? '',
comment: text,
comment: commentText,
marketUrl,
unsubscribeUrl,
betDescription,
@ -423,29 +413,24 @@ export const sendNewCommentEmail = async (
}
export const sendNewAnswerEmail = async (
answer: Answer,
contract: Contract
reason: notification_reason_types,
privateUser: PrivateUser,
name: string,
text: string,
contract: Contract,
avatarUrl?: string
) => {
// Send to just the creator for now.
const { creatorId: userId } = contract
const { creatorId } = contract
// Don't send the creator's own answers.
if (answer.userId === userId) return
if (privateUser.id === creatorId) return
const privateUser = await getPrivateUser(userId)
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromAnswerEmails
)
return
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
await getDestinationsForUser(privateUser, reason)
if (!privateUser.email || !sendToEmail) return
const { question, creatorUsername, slug } = contract
const { name, avatarUrl, text } = answer
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 from = `${name} <info@manifold.markets>`
@ -474,12 +459,15 @@ export const sendInterestingMarketsEmail = async (
if (
!privateUser ||
!privateUser.email ||
privateUser?.unsubscribedFromWeeklyTrendingEmails
!privateUser.notificationSubscriptionTypes.trending_markets.includes(
'email'
)
)
return
const emailType = 'weekly-trending'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${privateUser.id}&type=${emailType}`
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'trending_markets' as keyof notification_subscription_types
}`
const { name } = user
const firstName = name.split(' ')[0]
@ -490,7 +478,7 @@ export const sendInterestingMarketsEmail = async (
'interesting-markets',
{
name: firstName,
unsubscribeLink: unsubscribeUrl,
unsubscribeUrl,
question1Title: contractsToSend[0].question,
question1Link: contractUrl(contractsToSend[0]),
@ -522,3 +510,37 @@ function contractUrl(contract: Contract) {
function imageSourceUrl(contract: 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>`,
}
)
}

View File

@ -3,7 +3,6 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract'
import { getPrivateUser, getUserByUsername } from './utils'
import { sendMarketCloseEmail } from './emails'
import { createNotification } from './create-notification'
export const marketCloseNotifications = functions
@ -56,7 +55,6 @@ async function sendMarketCloseEmails() {
const privateUser = await getPrivateUser(user.id)
if (!privateUser) continue
await sendMarketCloseEmail(user, privateUser, contract)
await createNotification(
contract.id,
'contract',

View File

@ -1,14 +1,13 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { compact, uniq } from 'lodash'
import { compact } from 'lodash'
import { getContract, getUser, getValues } from './utils'
import { ContractComment } from '../../common/comment'
import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer'
import {
createCommentOrAnswerOrUpdatedContractNotification,
filterUserIdsForOnlyFollowerIds,
replied_users_info,
} from './create-notification'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { addUserToContractFollowers } from './follow-market'
@ -77,16 +76,46 @@ export const onCreateCommentOnContract = functions
const comments = await getValues<ContractComment>(
firestore.collection('contracts').doc(contractId).collection('comments')
)
const relatedSourceType = comment.replyToCommentId
? 'comment'
: comment.answerOutcome
const repliedToType = answer
? 'answer'
: comment.replyToCommentId
? 'comment'
: undefined
const repliedUserId = comment.replyToCommentId
? comments.find((c) => c.id === comment.replyToCommentId)?.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(
comment.id,
'comment',
@ -96,31 +125,8 @@ export const onCreateCommentOnContract = functions
richTextToString(comment.content),
contract,
{
relatedSourceType,
repliedUserId,
taggedUserIds: compact(parseMentions(comment.content)),
repliedUsersInfo: repliedUsers,
taggedUserIds: mentionedUsers,
}
)
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
)
)
)
})

View File

@ -1,7 +1,7 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createNotification } from './create-notification'
import { createNewContractNotification } from './create-notification'
import { Contract } from '../../common/contract'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core'
@ -21,13 +21,11 @@ export const onCreateContract = functions
const mentioned = parseMentions(desc)
await addUserToContractFollowers(contract.id, contractCreator.id)
await createNotification(
contract.id,
'contract',
'created',
await createNewContractNotification(
contractCreator,
contract,
eventId,
richTextToString(desc),
{ contract, recipients: mentioned }
mentioned
)
})

View File

@ -13,32 +13,7 @@ export const onUpdateContract = functions.firestore
if (!contractUpdater) throw new Error('Could not find contract updater')
const previousValue = change.before.data() as Contract
if (previousValue.isResolved !== contract.isResolved) {
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 (
if (
previousValue.closeTime !== contract.closeTime ||
previousValue.question !== contract.question
) {

View File

@ -1,6 +1,6 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { difference, mapValues, groupBy, sumBy } from 'lodash'
import { mapValues, groupBy, sumBy } from 'lodash'
import {
Contract,
@ -8,10 +8,8 @@ import {
MultipleChoiceContract,
RESOLUTIONS,
} from '../../common/contract'
import { User } from '../../common/user'
import { Bet } from '../../common/bet'
import { getUser, isProd, payUser } from './utils'
import { sendMarketResolutionEmail } from './emails'
import {
getLoanPayouts,
getPayouts,
@ -23,7 +21,7 @@ import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api'
import { getContractBetMetrics } from '../../common/calculate'
import { floatingEqual } from '../../common/util/math'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
const bodySchema = z.object({
contractId: z.string(),
@ -163,15 +161,48 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
await sendResolutionEmails(
bets,
userPayoutsWithoutLoans,
const userInvestments = mapValues(
groupBy(bets, (bet) => bet.userId),
(bets) => getContractBetMetrics(contract, bets).invested
)
let resolutionText = outcome ?? contract.question
if (
contract.outcomeType === 'FREE_RESPONSE' ||
contract.outcomeType === 'MULTIPLE_CHOICE'
) {
const answerText = contract.answers.find(
(answer) => answer.id === outcome
)?.text
if (answerText) resolutionText = answerText
} else if (contract.outcomeType === 'BINARY') {
if (resolutionText === 'MKT' && probabilityInt)
resolutionText = `${probabilityInt}%`
else if (resolutionText === 'MKT') resolutionText = 'PROB'
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
if (resolutionText === 'MKT' && value) resolutionText = `${value}`
}
// TODO: this actually may be too slow to complete with a ton of users to notify?
await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'resolved',
creator,
creatorPayout,
contract.id + '-resolution',
resolutionText,
contract,
outcome,
resolutionProbability,
resolutions
undefined,
{
bets,
userInvestments,
userPayouts: userPayoutsWithoutLoans,
creator,
creatorPayout,
contract,
outcome,
resolutionProbability,
resolutions,
}
)
return updatedContract
@ -189,51 +220,6 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => {
.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) {
const { outcomeType } = contract

View 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())

View File

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

View File

@ -4,9 +4,11 @@ import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet'
import { Contract, CPMM } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user'
import { DAY_MS } from '../../common/util/time'
import { getLoanUpdates } from '../../common/loans'
import { scoreTraders, scoreCreators } from '../../common/scoring'
import {
calculateCreatorVolume,
calculateNewPortfolioMetrics,
@ -15,6 +17,7 @@ import {
computeVolume,
} from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate'
import { Group } from 'common/group'
const firestore = admin.firestore()
@ -24,16 +27,29 @@ export const updateMetrics = functions
.onRun(updateMetricsCore)
export async function updateMetricsCore() {
const [users, contracts, bets, allPortfolioHistories] = await Promise.all([
getValues<User>(firestore.collection('users')),
getValues<Contract>(firestore.collection('contracts')),
getValues<Bet>(firestore.collectionGroup('bets')),
getValues<PortfolioMetrics>(
firestore
.collectionGroup('portfolioHistory')
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
),
])
const [users, contracts, bets, allPortfolioHistories, groups] =
await Promise.all([
getValues<User>(firestore.collection('users')),
getValues<Contract>(firestore.collection('contracts')),
getValues<Bet>(firestore.collectionGroup('bets')),
getValues<PortfolioMetrics>(
firestore
.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(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
)
@ -41,6 +57,7 @@ export async function updateMetricsCore() {
const now = Date.now()
const betsByContract = groupBy(bets, (bet) => bet.contractId)
const contractUpdates = contracts
.filter((contract) => contract.id)
.map((contract) => {
@ -162,4 +179,48 @@ export async function updateMetricsCore() {
'set'
)
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 }

View File

@ -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>
)
}

View File

@ -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 (
<>
<AmountInput
@ -138,10 +150,10 @@ export function BuyAmountInput(props: {
<input
type="range"
min="0"
max="200"
value={amount ?? 0}
onChange={(e) => onAmountChange(parseInt(e.target.value))}
className="range range-lg z-40 mb-2 xl:hidden"
max="205"
value={getRaw(amount ?? 0)}
onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
className="range range-lg only-thumb z-40 mb-2 xl:hidden"
step="5"
/>
)}

View File

@ -1,5 +1,5 @@
import clsx from 'clsx'
import React, { useEffect, useRef, useState } from 'react'
import React, { useState } from 'react'
import { XIcon } from '@heroicons/react/solid'
import { Answer } from 'common/answer'
@ -25,8 +25,7 @@ import {
import { Bet } from 'common/bet'
import { track } from 'web/lib/service/analytics'
import { BetSignUpPrompt } from '../sign-up-prompt'
import { isIOS } from 'web/lib/util/device'
import { AlertBox } from '../alert-box'
import { WarningConfirmationButton } from '../warning-confirmation-button'
export function AnswerBetPanel(props: {
answer: Answer
@ -44,12 +43,6 @@ export function AnswerBetPanel(props: {
const [error, setError] = useState<string | undefined>()
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() {
if (!user || !betAmount) return
@ -116,6 +109,15 @@ export function AnswerBetPanel(props: {
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 (
<Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
<Row className="items-center justify-between self-stretch">
@ -144,25 +146,9 @@ export function AnswerBetPanel(props: {
error={error}
setError={setError}
disabled={isSubmitting}
inputRef={inputRef}
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">
<Row className="items-center justify-between text-sm">
<div className="text-gray-500">Probability</div>
@ -198,16 +184,17 @@ export function AnswerBetPanel(props: {
<Spacer h={6} />
{user ? (
<button
className={clsx(
<WarningConfirmationButton
warning={warning}
onSubmit={submitBet}
isSubmitting={isSubmitting}
disabled={!!betDisabled}
openModalButtonClass={clsx(
'btn self-stretch',
betDisabled ? 'btn-disabled' : 'btn-primary',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitBet}
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
/>
) : (
<BetSignUpPrompt />
)}

View File

@ -23,6 +23,7 @@ import { Avatar } from 'web/components/avatar'
import { Linkify } from 'web/components/linkify'
import { BuyButton } from 'web/components/yes-no-selector'
import { UserLink } from 'web/components/user-link'
import { Button } from 'web/components/button'
export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract
@ -30,14 +31,15 @@ export function AnswersPanel(props: {
const { contract } = props
const { creatorId, resolution, resolutions, totalBets, outcomeType } =
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(
answers.filter(
(answer) =>
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
totalBets[answer.id] > 0.000000001
),
answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)),
(answer) =>
answer.id === resolution || (resolutions && resolutions[answer.id])
)
@ -127,6 +129,17 @@ export function AnswersPanel(props: {
</div>
</div>
))}
<Row className={'justify-end'}>
{hasZeroBetAnswers && !showAllAnswers && (
<Button
color={'gray-white'}
onClick={() => setShowAllAnswers(true)}
size={'md'}
>
Show More
</Button>
)}
</Row>
</div>
</div>
)}
@ -194,7 +207,7 @@ function OpenAnswer(props: {
return (
<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
answer={answer}
contract={contract}

View File

@ -76,7 +76,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
if (existingAnswer) {
setAnswerError(
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
@ -237,7 +237,7 @@ const AnswerError = (props: { text: string; level: answerErrorLevel }) => {
}[level] ?? ''
return (
<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}
</div>

View File

@ -7,25 +7,19 @@ import { Row } from 'web/components/layout/row'
import { Subtitle } from 'web/components/subtitle'
import { useMemberGroups } from 'web/hooks/use-group'
import { filterDefined } from 'common/util/array'
import { keyBy } from 'lodash'
import { isArray, keyBy } from 'lodash'
import { User } from 'common/user'
import { Group } from 'common/group'
export function ArrangeHome(props: {
user: User | null | undefined
homeSections: { visible: string[]; hidden: string[] }
setHomeSections: (homeSections: {
visible: string[]
hidden: string[]
}) => void
homeSections: string[]
setHomeSections: (sections: string[]) => void
}) {
const { user, homeSections, setHomeSections } = props
const groups = useMemberGroups(user?.id) ?? []
const { itemsById, visibleItems, hiddenItems } = getHomeItems(
groups,
homeSections
)
const { itemsById, sections } = getHomeItems(groups, homeSections)
return (
<DragDropContext
@ -35,23 +29,16 @@ export function ArrangeHome(props: {
const item = itemsById[draggableId]
const newHomeSections = {
visible: visibleItems.map((item) => item.id),
hidden: hiddenItems.map((item) => item.id),
}
const newHomeSections = sections.map((section) => section.id)
const sourceSection = source.droppableId as 'visible' | 'hidden'
newHomeSections[sourceSection].splice(source.index, 1)
const destSection = destination.droppableId as 'visible' | 'hidden'
newHomeSections[destSection].splice(destination.index, 0, item.id)
newHomeSections.splice(source.index, 1)
newHomeSections.splice(destination.index, 0, item.id)
setHomeSections(newHomeSections)
}}
>
<Row className="relative max-w-lg gap-4">
<DraggableList items={visibleItems} title="Visible" />
<DraggableList items={hiddenItems} title="Hidden" />
<Row className="relative max-w-md gap-4">
<DraggableList items={sections} title="Sections" />
</Row>
</DragDropContext>
)
@ -64,16 +51,13 @@ function DraggableList(props: {
const { title, items } = props
return (
<Droppable droppableId={title.toLowerCase()}>
{(provided, snapshot) => (
{(provided) => (
<Col
{...provided.droppableProps}
ref={provided.innerRef}
className={clsx(
'width-[220px] flex-1 items-start rounded bg-gray-50 p-2',
snapshot.isDraggingOver && 'bg-gray-100'
)}
className={clsx('flex-1 items-stretch gap-1 rounded bg-gray-100 p-4')}
>
<Subtitle text={title} className="mx-2 !my-2" />
<Subtitle text={title} className="mx-2 !mt-0 !mb-4" />
{items.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided, snapshot) => (
@ -82,16 +66,13 @@ function DraggableList(props: {
{...provided.draggableProps}
{...provided.dragHandleProps}
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
className="h-5 w-5 flex-shrink-0 text-gray-500"
aria-hidden="true"
/>{' '}
{item.label}
<SectionItem
className={clsx(
snapshot.isDragging && 'z-[9000] bg-gray-200'
)}
item={item}
/>
</div>
)}
</Draggable>
@ -103,15 +84,36 @@ function DraggableList(props: {
)
}
export const getHomeItems = (
groups: Group[],
homeSections: { visible: string[]; hidden: string[] }
) => {
const SectionItem = (props: {
item: { id: string; label: 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 = [
{ label: 'Daily movers', id: 'daily-movers' },
{ label: 'Trending', id: 'score' },
{ label: 'Newest', id: 'newest' },
{ label: 'Close date', id: 'close-date' },
{ label: 'Your trades', id: 'your-bets' },
{ label: 'New for you', id: 'newest' },
...groups.map((g) => ({
label: g.name,
id: g.id,
@ -119,23 +121,13 @@ export const getHomeItems = (
]
const itemsById = keyBy(items, 'id')
const { visible, hidden } = homeSections
const sectionItems = filterDefined(sections.map((id) => itemsById[id]))
const [visibleItems, hiddenItems] = [
filterDefined(visible.map((id) => itemsById[id])),
filterDefined(hidden.map((id) => itemsById[id])),
]
// Add unmentioned items to the visible list.
visibleItems.push(
...items.filter(
(item) => !visibleItems.includes(item) && !hiddenItems.includes(item)
)
)
// Add unmentioned items to the end.
sectionItems.push(...items.filter((item) => !sectionItems.includes(item)))
return {
visibleItems,
hiddenItems,
sections: sectionItems,
itemsById,
}
}

View File

@ -60,7 +60,7 @@ export default function BetButton(props: {
)}
</Col>
<Modal open={open} setOpen={setOpen}>
<Modal open={open} setOpen={setOpen} position="center">
<SimpleBetPanel
className={betPanelClassName}
contract={contract}

View File

@ -40,7 +40,9 @@ import { LimitBets } from './limit-bets'
import { PillButton } from './buttons/pill-button'
import { YesNoSelector } from './yes-no-selector'
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: {
contract: CPMMBinaryContract | PseudoNumericContract
@ -89,10 +91,7 @@ export function BetPanel(props: {
/>
</>
) : (
<>
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</>
<MarketIntroPanel />
)}
</Col>
@ -184,17 +183,13 @@ function BuyPanel(props: {
const [inputRef, focusAmountInput] = useFocus()
// useEffect(() => {
// if (selected) {
// if (isIOS()) window.scrollTo(0, window.scrollY + 200)
// focusAmountInput()
// }
// }, [selected, focusAmountInput])
function onBetChoice(choice: 'YES' | 'NO') {
setOutcome(choice)
setWasSubmitted(false)
focusAmountInput()
if (!isIOS() && !isAndroid()) {
focusAmountInput()
}
}
function onBetChange(newAmount: number | undefined) {
@ -274,25 +269,15 @@ function BuyPanel(props: {
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
const warning =
(betAmount ?? 0) > 10 &&
bankrollFraction >= 0.5 &&
bankrollFraction <= 1 ? (
<AlertBox
title="Whoa, there!"
text={`You might not want to spend ${formatPercent(
(betAmount ?? 0) >= 100 && bankrollFraction >= 0.5 && bankrollFraction <= 1
? `You might not want to spend ${formatPercent(
bankrollFraction
)} of your balance on a single trade. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0
)}`}
/>
) : (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1 ? (
<AlertBox
title="Whoa, there!"
text={`Are you sure you want to move the market by ${displayedDifference}?`}
/>
) : (
<></>
)
)}`
: (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1
? `Are you sure you want to move the market by ${displayedDifference}?`
: undefined
return (
<Col className={hidden ? 'hidden' : ''}>
@ -325,8 +310,6 @@ function BuyPanel(props: {
showSliderOnMobile
/>
{warning}
<Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm">
<div className="text-gray-500">
@ -367,20 +350,20 @@ function BuyPanel(props: {
<Spacer h={8} />
{user && (
<button
className={clsx(
<WarningConfirmationButton
warning={warning}
onSubmit={submitBet}
isSubmitting={isSubmitting}
disabled={!!betDisabled}
openModalButtonClass={clsx(
'btn mb-2 flex-1',
betDisabled
? 'btn-disabled'
: outcome === 'YES'
? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500',
isSubmitting ? 'loading' : ''
: 'border-none bg-red-400 hover:bg-red-500'
)}
onClick={betDisabled ? undefined : submitBet}
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
/>
)}
{wasSubmitted && <div className="mt-4">Trade submitted!</div>}
@ -750,9 +733,7 @@ function QuickOrLimitBet(props: {
return (
<Row className="align-center mb-4 justify-between">
<div className="mr-2 -ml-2 shrink-0 text-3xl sm:-ml-0 sm:text-4xl">
Predict
</div>
<div className="mr-2 -ml-2 shrink-0 text-3xl sm:-ml-0">Predict</div>
{!hideToggle && (
<Row className="mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2">
<PillButton

View File

@ -4,7 +4,6 @@ import clsx from 'clsx'
import { User } from 'common/user'
import { useEffect, useState } from 'react'
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 { Avatar } from './avatar'
import { TextEditor, useTextEditor } from './editor'
@ -80,7 +79,6 @@ export function CommentInputTextArea(props: {
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
isSubmitting: boolean
submitOnEnter?: boolean
presetId?: string
}) {
const {
@ -90,11 +88,8 @@ export function CommentInputTextArea(props: {
submitComment,
presetId,
isSubmitting,
submitOnEnter,
replyToUser,
} = props
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
useEffect(() => {
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
@ -108,15 +103,14 @@ export function CommentInputTextArea(props: {
if (!editor) {
return
}
// submit on Enter key
// Submit on ctrl+enter or mod+enter key
editor.setOptions({
editorProps: {
handleKeyDown: (view, event) => {
if (
submitOnEnter &&
event.key === 'Enter' &&
!event.shiftKey &&
(!isMobile || event.ctrlKey || event.metaKey) &&
(event.ctrlKey || event.metaKey) &&
// mention list is closed
!(view.state as any).mention$.active
) {

View File

@ -47,13 +47,13 @@ export function ConfirmationButton(props: {
{children}
<Row className="gap-4">
<div
className={clsx('btn normal-case', cancelBtn?.className)}
className={clsx('btn', cancelBtn?.className)}
onClick={() => updateOpen(false)}
>
{cancelBtn?.label ?? 'Cancel'}
</div>
<div
className={clsx('btn normal-case', submitBtn?.className)}
className={clsx('btn', submitBtn?.className)}
onClick={
onSubmitWithSuccess
? () =>
@ -69,7 +69,7 @@ export function ConfirmationButton(props: {
</Col>
</Modal>
<div
className={clsx('btn normal-case', openModalBtn.className)}
className={clsx('btn', openModalBtn.className)}
onClick={() => updateOpen(true)}
>
{openModalBtn.icon}

View File

@ -13,7 +13,6 @@ import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { useBets } from 'web/hooks/use-bets'
import { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity'
import { BetSignUpPrompt } from '../sign-up-prompt'
@ -27,24 +26,23 @@ export function ContractTabs(props: {
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, user, tips } = props
const { contract, user, bets, tips } = props
const { outcomeType } = contract
const bets = useBets(contract.id) ?? props.bets
const lps = useLiquidity(contract.id) ?? []
const lps = useLiquidity(contract.id)
const userBets =
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
)
const visibleLps = lps.filter((l) => !l.isAnte && l.amount > 0)
const visibleLps = lps?.filter((l) => !l.isAnte && l.amount > 0)
// Load comments here, so the badge count will be correct
const updatedComments = useComments(contract.id)
const comments = updatedComments ?? props.comments
const betActivity = (
const betActivity = visibleLps && (
<ContractBetsActivity
contract={contract}
bets={visibleBets}

View File

@ -2,74 +2,69 @@ import clsx from 'clsx'
import { contractPath } from 'web/lib/firebase/contracts'
import { CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format'
import { useProbChanges } from 'web/hooks/use-prob-changes'
import { linkClass, SiteLink } from '../site-link'
import { SiteLink } from '../site-link'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { useState } from 'react'
import { LoadingIndicator } from '../loading-indicator'
export function ProbChangeTable(props: { userId: string | undefined }) {
const { userId } = props
export function ProbChangeTable(props: {
changes:
| { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] }
| undefined
}) {
const { changes } = props
const changes = useProbChanges(userId ?? '')
const [expanded, setExpanded] = useState(false)
if (!changes) {
return null
}
const count = expanded ? 16 : 4
if (!changes) return <LoadingIndicator />
const { positiveChanges, negativeChanges } = changes
const filteredPositiveChanges = positiveChanges.slice(0, count / 2)
const filteredNegativeChanges = negativeChanges.slice(0, count / 2)
const filteredChanges = [
...filteredPositiveChanges,
...filteredNegativeChanges,
]
const threshold = 0.075
const countOverThreshold = Math.max(
positiveChanges.findIndex((c) => c.probChanges.day < threshold) + 1,
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 (
<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="flex-1 divide-y">
{filteredChanges.slice(0, count / 2).map((contract) => (
<Row className="items-center hover:bg-gray-100">
<ProbChange
className="p-4 text-right text-xl"
contract={contract}
/>
<SiteLink
className="p-4 pl-2 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-2">{contract.question}</span>
</SiteLink>
</Row>
))}
</Col>
<Col className="flex-1 divide-y">
{filteredChanges.slice(count / 2).map((contract) => (
<Row className="items-center hover:bg-gray-100">
<ProbChange
className="p-4 text-right text-xl"
contract={contract}
/>
<SiteLink
className="p-4 pl-2 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-2">{contract.question}</span>
</SiteLink>
</Row>
))}
</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="flex-1 divide-y">
{filteredPositiveChanges.map((contract) => (
<Row className="items-center hover:bg-gray-100">
<ProbChange
className="p-4 text-right text-xl"
contract={contract}
/>
<SiteLink
className="p-4 pl-2 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-2">{contract.question}</span>
</SiteLink>
</Row>
))}
</Col>
<Col className="flex-1 divide-y">
{filteredNegativeChanges.map((contract) => (
<Row className="items-center hover:bg-gray-100">
<ProbChange
className="p-4 text-right text-xl"
contract={contract}
/>
<SiteLink
className="p-4 pl-2 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-2">{contract.question}</span>
</SiteLink>
</Row>
))}
</Col>
<div
className={clsx(linkClass, 'cursor-pointer self-end')}
onClick={() => setExpanded(!expanded)}
>
{expanded ? 'Show less' : 'Show more'}
</div>
</Col>
)
}

View File

@ -4,7 +4,7 @@ import { EyeIcon } from '@heroicons/react/outline'
import React from 'react'
import clsx from 'clsx'
export const FollowMarketModal = (props: {
export const WatchMarketModal = (props: {
open: boolean
setOpen: (b: boolean) => void
title?: string
@ -18,20 +18,21 @@ export const FollowMarketModal = (props: {
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What is watching?</span>
<span className={'ml-2'}>
You can receive notifications on questions you're interested in by
You'll receive notifications on markets by betting, commenting, or
clicking the
<EyeIcon
className={clsx('ml-1 inline h-6 w-6 align-top')}
aria-hidden="true"
/>
button on a question.
button on them.
</span>
<span className={'text-indigo-700'}>
What types of notifications will I receive?
</span>
<span className={'ml-2'}>
You'll receive in-app notifications for new comments, answers, and
updates to the question.
You'll receive notifications for new comments, answers, and updates
to the question. See the notifications settings pages to customize
which types of notifications you receive on watched markets.
</span>
</Col>
</Col>

View File

@ -2,6 +2,7 @@ import CharacterCount from '@tiptap/extension-character-count'
import Placeholder from '@tiptap/extension-placeholder'
import {
useEditor,
BubbleMenu,
EditorContent,
JSONContent,
Content,
@ -18,20 +19,25 @@ import { uploadImage } from 'web/lib/firebase/storage'
import { useMutation } from 'react-query'
import { FileUploadButton } from './file-upload-button'
import { linkClass } from './site-link'
import { useUsers } from 'web/hooks/use-users'
import { mentionSuggestion } from './editor/mention-suggestion'
import { DisplayMention } from './editor/mention'
import Iframe from 'common/util/tiptap-iframe'
import TiptapTweet from './editor/tiptap-tweet'
import { EmbedModal } from './editor/embed-modal'
import {
CheckIcon,
CodeIcon,
PhotographIcon,
PresentationChartLineIcon,
TrashIcon,
} from '@heroicons/react/solid'
import { MarketModal } from './editor/market-modal'
import { insertContent } from './editor/utils'
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({
HTMLAttributes: {
@ -68,8 +74,6 @@ export function useTextEditor(props: {
}) {
const { placeholder, max, defaultValue = '', disabled, simple } = props
const users = useUsers()
const editorClass = clsx(
proseClass,
!simple && 'min-h-[6em]',
@ -78,32 +82,27 @@ export function useTextEditor(props: {
'[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, emebeds
)
const editor = useEditor(
{
editorProps: { attributes: { class: editorClass } },
extensions: [
StarterKit.configure({
heading: simple ? false : { levels: [1, 2, 3] },
horizontalRule: simple ? false : {},
}),
Placeholder.configure({
placeholder,
emptyEditorClass:
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
}),
CharacterCount.configure({ limit: max }),
simple ? DisplayImage : Image,
DisplayLink,
DisplayMention.configure({
suggestion: mentionSuggestion(users),
}),
Iframe,
TiptapTweet,
],
content: defaultValue,
},
[!users.length] // passed as useEffect dependency. (re-render editor when users load, to update mention menu)
)
const editor = useEditor({
editorProps: { attributes: { class: editorClass } },
extensions: [
StarterKit.configure({
heading: simple ? false : { levels: [1, 2, 3] },
horizontalRule: simple ? false : {},
}),
Placeholder.configure({
placeholder,
emptyEditorClass:
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
}),
CharacterCount.configure({ limit: max }),
simple ? DisplayImage : Image,
DisplayLink,
DisplayMention.configure({ suggestion: mentionSuggestion }),
Iframe,
TiptapTweet,
],
content: defaultValue,
})
const upload = useUploadMutation(editor)
@ -149,6 +148,66 @@ function isValidIframe(text: string) {
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: {
editor: Editor | null
upload: ReturnType<typeof useUploadMutation>
@ -163,6 +222,7 @@ export function TextEditor(props: {
{/* hide placeholder when focused */}
<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">
<FloatingMenu editor={editor} />
<EditorContent editor={editor} />
{/* Toolbar, with buttons for images and embeds */}
<div className="flex h-9 items-center gap-5 pl-4 pr-1">

View File

@ -1,9 +1,9 @@
import type { MentionOptions } from '@tiptap/extension-mention'
import { ReactRenderer } from '@tiptap/react'
import { User } from 'common/user'
import { searchInAny } from 'common/util/parse'
import { orderBy } from 'lodash'
import tippy from 'tippy.js'
import { getCachedUsers } from 'web/hooks/use-users'
import { MentionList } from './mention-list'
type Suggestion = MentionOptions['suggestion']
@ -12,10 +12,12 @@ const beginsWith = (text: string, query: string) =>
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
// copied from https://tiptap.dev/api/nodes/mention#usage
export const mentionSuggestion = (users: User[]): Suggestion => ({
items: ({ query }) =>
export const mentionSuggestion: Suggestion = {
items: async ({ query }) =>
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)),
'followerCountCached',
@ -38,7 +40,7 @@ export const mentionSuggestion = (users: User[]): Suggestion => ({
popup = tippy('body', {
getReferenceClientRect: props.clientRect as any,
appendTo: () => document.body,
content: component.element,
content: component?.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
@ -46,27 +48,27 @@ export const mentionSuggestion = (users: User[]): Suggestion => ({
})
},
onUpdate(props) {
component.updateProps(props)
component?.updateProps(props)
if (!props.clientRect) {
return
}
popup[0].setProps({
popup?.[0].setProps({
getReferenceClientRect: props.clientRect as any,
})
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup[0].hide()
popup?.[0].hide()
return true
}
return (component.ref as any)?.onKeyDown(props)
return (component?.ref as any)?.onKeyDown(props)
},
onExit() {
popup[0].destroy()
component.destroy()
popup?.[0].destroy()
component?.destroy()
},
}
},
})
}

View File

@ -14,13 +14,10 @@ import { OutcomeLabel } from 'web/components/outcome-label'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { firebaseLogin } from 'web/lib/firebase/users'
import { createCommentOnContract } from 'web/lib/firebase/comments'
import { BetStatusText } from 'web/components/feed/feed-bets'
import { Col } from 'web/components/layout/col'
import { getProbability } from 'common/calculate'
import { track } from 'web/lib/service/analytics'
import { Tipper } from '../tipper'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { Content } from '../editor'
import { Editor } from '@tiptap/react'
import { UserLink } from 'web/components/user-link'
@ -302,74 +299,14 @@ export function ContractCommentInput(props: {
const { id } = mostRecentCommentableBet || { id: undefined }
return (
<Col>
<CommentBetArea
betsByCurrentUser={props.betsByCurrentUser}
contract={props.contract}
commentsByCurrentUser={props.commentsByCurrentUser}
parentAnswerOutcome={props.parentAnswerOutcome}
user={useUser()}
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>
<CommentInput
replyToUser={props.replyToUser}
parentAnswerOutcome={props.parentAnswerOutcome}
parentCommentId={props.parentCommentId}
onSubmitComment={onSubmitComment}
className={props.className}
presetId={id}
/>
)
}

View File

@ -11,7 +11,7 @@ import { User } from 'common/user'
import { useContractFollows } from 'web/hooks/use-follows'
import { firebaseLogin, updateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
import { WatchMarketModal } from 'web/components/contract/watch-market-modal'
import { useState } from 'react'
import { Col } from 'web/components/layout/col'
@ -65,7 +65,7 @@ export const FollowMarketButton = (props: {
Watch
</Col>
)}
<FollowMarketModal
<WatchMarketModal
open={open}
setOpen={setOpen}
title={`You ${

View File

@ -22,7 +22,7 @@ export function GroupAboutPost(props: {
const post = usePost(group.aboutPostId) ?? props.post
return (
<div className="rounded-md bg-white p-4">
<div className="rounded-md bg-white p-4 ">
{isEditable ? (
<RichEditGroupAboutPost group={group} post={post} />
) : (

View File

@ -1,3 +1,4 @@
import Image from 'next/future/image'
import { SparklesIcon } from '@heroicons/react/solid'
import { Contract } from 'common/contract'
@ -18,7 +19,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
return (
<>
<Col className="mb-6 rounded-xl sm:m-12 sm:mt-0">
<img
<Image
height={250}
width={250}
className="self-center"

View File

@ -8,9 +8,10 @@ export function Modal(props: {
open: boolean
setOpen: (open: boolean) => void
size?: 'sm' | 'md' | 'lg' | 'xl'
position?: 'center' | 'top' | 'bottom'
className?: string
}) {
const { children, open, setOpen, size = 'md', className } = props
const { children, position, open, setOpen, size = 'md', className } = props
const sizeClass = {
sm: 'max-w-sm',
@ -19,6 +20,12 @@ export function Modal(props: {
xl: 'max-w-5xl',
}[size]
const positionClass = {
center: 'items-center',
top: 'items-start',
bottom: 'items-end',
}[position ?? 'bottom']
return (
<Transition.Root show={open} as={Fragment}>
<Dialog
@ -26,7 +33,12 @@ export function Modal(props: {
className="fixed inset-0 z-50 overflow-y-auto"
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
as={Fragment}
enter="ease-out duration-300"

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -13,6 +13,7 @@ import {
getUserBetContractsQuery,
} from 'web/lib/firebase/contracts'
import { useQueryClient } from 'react-query'
import { MINUTE_MS } from 'common/util/time'
export const useContracts = () => {
const [contracts, setContracts] = useState<Contract[] | undefined>()
@ -96,8 +97,10 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => {
export const usePrefetchUserBetContracts = (userId: string) => {
const queryClient = useQueryClient()
return queryClient.prefetchQuery(['contracts', 'bets', userId], () =>
getUserBetContracts(userId)
return queryClient.prefetchQuery(
['contracts', 'bets', userId],
() => getUserBetContracts(userId),
{ staleTime: 5 * MINUTE_MS }
)
}

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { notification_subscribe_types, PrivateUser } from 'common/user'
import { PrivateUser } from 'common/user'
import { Notification } from 'common/notification'
import { getNotificationsQuery } from 'web/lib/firebase/notifications'
import { groupBy, map, partition } from 'lodash'
@ -23,11 +23,8 @@ function useNotifications(privateUser: PrivateUser) {
if (!result.data) return undefined
const notifications = result.data as Notification[]
return getAppropriateNotifications(
notifications,
privateUser.notificationPreferences
).filter((n) => !n.isSeenOnHref)
}, [privateUser.notificationPreferences, result.data])
return notifications.filter((n) => !n.isSeenOnHref)
}, [result.data])
return notifications
}
@ -111,29 +108,3 @@ export function groupNotifications(notifications: Notification[]) {
})
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
}

View File

@ -1,6 +1,6 @@
import { useQueryClient } from 'react-query'
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 {
getPortfolioHistory,
getPortfolioHistoryQuery,
@ -15,8 +15,10 @@ const getCutoff = (period: Period) => {
export const usePrefetchPortfolioHistory = (userId: string, period: Period) => {
const queryClient = useQueryClient()
const cutoff = getCutoff(period)
return queryClient.prefetchQuery(['portfolio-history', userId, cutoff], () =>
getPortfolioHistory(userId, cutoff)
return queryClient.prefetchQuery(
['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 result = useFirestoreQueryData(
['portfolio-history', userId, cutoff],
getPortfolioHistoryQuery(userId, cutoff)
getPortfolioHistoryQuery(userId, cutoff),
{},
{ staleTime: 15 * MINUTE_MS }
)
return result.data
}

View File

@ -7,10 +7,15 @@ import {
getUserBetsQuery,
listenForUserContractBets,
} from 'web/lib/firebase/bets'
import { MINUTE_MS } from 'common/util/time'
export const usePrefetchUserBets = (userId: string) => {
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) => {

View File

@ -6,7 +6,8 @@ import { useFollows } from './use-follows'
import { useUser } from './use-user'
import { useFirestoreQueryData } from '@react-query-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 = () => {
const result = useFirestoreQueryData<DocumentData, User[]>(['users'], users, {
@ -16,6 +17,10 @@ export const useUsers = () => {
return result.data ?? []
}
const q = new QueryClient()
export const getCachedUsers = async () =>
q.fetchQuery(['users'], getUsers, { staleTime: Infinity })
export const usePrivateUsers = () => {
const result = useFirestoreQueryData<DocumentData, PrivateUser[]>(
['private users'],

View File

@ -24,7 +24,6 @@ import { Contract } from 'common/contract'
import { getContractFromId, updateContract } from 'web/lib/firebase/contracts'
import { db } from 'web/lib/firebase/init'
import { filterDefined } from 'common/util/array'
import { getUser } from 'web/lib/firebase/users'
export const groups = coll<Group>('groups')
export const groupMembers = (groupId: string) =>
@ -253,7 +252,7 @@ export function getGroupLinkToDisplay(contract: Contract) {
return groupToDisplay
}
export async function listMembers(group: Group) {
export async function listMemberIds(group: Group) {
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)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -12,3 +12,7 @@ export function isIOS() {
(navigator.userAgent.includes('Mac') && 'ontouchend' in document)
)
}
export function isAndroid() {
return navigator.userAgent.includes('Android')
}

View File

@ -5,7 +5,7 @@ import { Comment } from 'common/comment'
import { Contract } from 'common/contract'
import { User } from 'common/user'
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 { richTextToString } from 'common/util/parse'
@ -121,7 +121,7 @@ export function toLiteMarket(contract: Contract): LiteMarket {
: closeTime,
question,
tags,
url: `https://manifold.markets/${creatorUsername}/${slug}`,
url: `https://${DOMAIN}/${creatorUsername}/${slug}`,
pool,
probability,
p,

View File

@ -178,7 +178,7 @@ export default function Charity(props: {
className="input input-bordered mb-6 w-full"
/>
</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) => (
<CharityCard
charity={charity}
@ -203,18 +203,26 @@ export default function Charity(props: {
></iframe>
</div>
<div className="mt-10 text-gray-500">
<span className="font-semibold">Notes</span>
<br />
- Don't see your favorite charity? Recommend it by emailing
charity@manifold.markets!
<br />
- Manifold is not affiliated with non-Featured charities; we're just
fans of their work.
<br />
- As Manifold itself is a for-profit entity, your contributions will
not be tax deductible.
<br />- Donations + matches are wired once each quarter.
<div className="prose mt-10 max-w-none text-gray-500">
<span className="text-lg font-semibold">Notes</span>
<ul>
<li>
Don't see your favorite charity? Recommend it by emailing{' '}
<a href="mailto:charity@manifold.markets?subject=Add%20Charity">
charity@manifold.markets
</a>
!
</li>
<li>
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>
</Col>
</Page>

View File

@ -16,14 +16,9 @@ export default function Home() {
useTracking('edit home')
const [homeSections, setHomeSections] = useState(
user?.homeSections ?? { visible: [], hidden: [] }
)
const [homeSections, setHomeSections] = useState(user?.homeSections ?? [])
const updateHomeSections = (newHomeSections: {
visible: string[]
hidden: string[]
}) => {
const updateHomeSections = (newHomeSections: string[]) => {
if (!user) return
updateUser(user.id, { homeSections: newHomeSections })
setHomeSections(newHomeSections)
@ -31,7 +26,7 @@ export default function Home() {
return (
<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'}>
<Title text="Edit your home page" />
<DoneButton />

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React from 'react'
import Router from 'next/router'
import {
PencilIcon,
@ -28,6 +28,7 @@ import { groupPath } from 'web/lib/firebase/groups'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { calculatePortfolioProfit } from 'common/calculate-metrics'
import { formatMoney } from 'common/util/format'
import { useProbChanges } from 'web/hooks/use-prob-changes'
const Home = () => {
const user = useUser()
@ -38,10 +39,7 @@ const Home = () => {
const groups = useMemberGroups(user?.id) ?? []
const [homeSections] = useState(
user?.homeSections ?? { visible: [], hidden: [] }
)
const { visibleItems } = getHomeItems(groups, homeSections)
const { sections } = getHomeItems(groups, user?.homeSections ?? [])
return (
<Page>
@ -54,29 +52,19 @@ const Home = () => {
<DailyProfitAndBalance userId={user?.id} />
<div className="text-xl text-gray-800">Daily movers</div>
<ProbChangeTable userId={user?.id} />
{visibleItems.map((item) => {
{sections.map((item) => {
const { id } = item
if (id === 'your-bets') {
return (
<SearchSection
key={id}
label={'Your trades'}
sort={'newest'}
user={user}
yourBets
/>
)
if (id === 'daily-movers') {
return <DailyMoversSection key={id} userId={user?.id} />
}
const sort = SORTS.find((sort) => sort.value === id)
if (sort)
return (
<SearchSection
key={id}
label={sort.label}
label={sort.value === 'newest' ? 'New for you' : sort.label}
sort={sort.value}
followed={sort.value === 'newest'}
user={user}
/>
)
@ -103,11 +91,12 @@ const Home = () => {
function SearchSection(props: {
label: string
user: User | null | undefined
user: User | null | undefined | undefined
sort: Sort
yourBets?: boolean
followed?: boolean
}) {
const { label, user, sort, yourBets } = props
const { label, user, sort, yourBets, followed } = props
const href = `/home?s=${sort}`
return (
@ -122,7 +111,13 @@ function SearchSection(props: {
<ContractSearch
user={user}
defaultSort={sort}
additionalFilter={yourBets ? { yourBets: true } : { followed: true }}
additionalFilter={
yourBets
? { yourBets: true }
: followed
? { followed: true }
: undefined
}
noControls
maxResults={6}
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
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 }) {
const { className } = props
@ -186,14 +202,14 @@ function DailyProfitAndBalance(props: {
return (
<div className={clsx(className, 'text-lg')}>
<span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}>
{profit >= 0 ? '+' : '-'}
{profit >= 0 && '+'}
{formatMoney(profit)}
</span>{' '}
profit and{' '}
<span
className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')}
>
{balanceChange >= 0 ? '+' : '-'}
{balanceChange >= 0 && '+'}
{formatMoney(balanceChange)}
</span>{' '}
balance today

View File

@ -1,28 +1,28 @@
import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { debounce, sortBy, take } from 'lodash'
import { SearchIcon } from '@heroicons/react/outline'
import { toast } from 'react-hot-toast'
import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Page } from 'web/components/page'
import { listAllBets } from 'web/lib/firebase/bets'
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
import {
addContractToGroup,
getGroupBySlug,
groupPath,
joinGroup,
listMembers,
listMemberIds,
updateGroup,
} from 'web/lib/firebase/groups'
import { Row } from 'web/components/layout/row'
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group'
import { scoreCreators, scoreTraders } from 'common/scoring'
import {
useGroup,
useGroupContractIds,
useMemberIds,
} from 'web/hooks/use-group'
import { Leaderboard } from 'web/components/leaderboard'
import { formatMoney } from 'common/util/format'
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 { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { ContractSearch } from 'web/components/contract-search'
import { FollowList } from 'web/components/follow-list'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { searchInAny } from 'common/util/parse'
import { CopyLinkButton } from 'web/components/copy-link-button'
import { ENV_CONFIG } from 'common/envs/constants'
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 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 contracts =
@ -71,33 +69,24 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
: 'open'
const aboutPost =
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 creatorScores = scoreCreators(contracts)
const traderScores = scoreTraders(contracts, bets)
const [topCreators, topTraders] =
(members && [
toTopUsers(creatorScores, members),
toTopUsers(traderScores, members),
]) ??
[]
const cachedTopTraderIds =
(group && group.cachedLeaderboard?.topTraders) ?? []
const cachedTopCreatorIds =
(group && group.cachedLeaderboard?.topCreators) ?? []
const topTraders = await toTopUsers(cachedTopTraderIds)
const topCreators = await toTopUsers(cachedTopCreatorIds)
const creator = await creatorPromise
// Only count unresolved markets
const contractsCount = contracts.filter((c) => !c.isResolved).length
return {
props: {
contractsCount,
group,
members,
memberIds,
creator,
traderScores,
topTraders,
creatorScores,
topCreators,
messages,
aboutPost,
@ -107,19 +96,6 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
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() {
return { paths: [], fallback: 'blocking' }
}
@ -132,39 +108,25 @@ const groupSubpages = [
] as const
export default function GroupPage(props: {
contractsCount: number
group: Group | null
members: User[]
memberIds: string[]
creator: User
traderScores: { [userId: string]: number }
topTraders: User[]
creatorScores: { [userId: string]: number }
topCreators: User[]
topTraders: { user: User; score: number }[]
topCreators: { user: User; score: number }[]
messages: GroupComment[]
aboutPost: Post
suggestedFilter: 'open' | 'all'
}) {
props = usePropz(props, getStaticPropz) ?? {
contractsCount: 0,
group: null,
members: [],
memberIds: [],
creator: null,
traderScores: {},
topTraders: [],
creatorScores: {},
topCreators: [],
messages: [],
suggestedFilter: 'open',
}
const {
contractsCount,
creator,
traderScores,
topTraders,
creatorScores,
topCreators,
suggestedFilter,
} = props
const { creator, topTraders, topCreators, suggestedFilter } = props
const router = useRouter()
const { slugs } = router.query as { slugs: string[] }
@ -175,7 +137,7 @@ export default function GroupPage(props: {
const user = useUser()
const isAdmin = useAdmin()
const members = useMembers(group?.id) ?? props.members
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
useSaveReferral(user, {
defaultReferrerUsername: creator.username,
@ -186,18 +148,25 @@ export default function GroupPage(props: {
return <Custom404 />
}
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 = (
<Col>
<GroupLeaderboards
traderScores={traderScores}
creatorScores={creatorScores}
topTraders={topTraders}
topCreators={topCreators}
members={members}
user={user}
/>
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
<GroupLeaderboard
topUsers={topTraders}
title="🏅 Top traders"
header="Profit"
maxToShow={maxLeaderboardSize}
/>
<GroupLeaderboard
topUsers={topCreators}
title="🏅 Top creators"
header="Market volume"
maxToShow={maxLeaderboardSize}
/>
</div>
</Col>
)
@ -216,7 +185,7 @@ export default function GroupPage(props: {
creator={creator}
isCreator={!!isCreator}
user={user}
members={members}
memberIds={memberIds}
/>
</Col>
)
@ -233,7 +202,6 @@ export default function GroupPage(props: {
const tabs = [
{
badge: `${contractsCount}`,
title: 'Markets',
content: questionsTab,
href: groupPath(group.slug, 'markets'),
@ -312,9 +280,9 @@ function GroupOverview(props: {
creator: User
user: User | null | undefined
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 } = {
Closed: 'false',
Open: 'true',
@ -333,7 +301,7 @@ function GroupOverview(props: {
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
group.slug
)}${postFix}`
const isMember = user ? members.map((m) => m.id).includes(user.id) : false
const isMember = user ? memberIds.includes(user.id) : false
return (
<>
@ -399,155 +367,37 @@ function GroupOverview(props: {
/>
</Col>
)}
<Col className={'mt-2'}>
<div className="mb-2 text-lg">Members</div>
<GroupMemberSearch members={members} group={group} />
</Col>
</Col>
</>
)
}
function SearchBar(props: { setQuery: (query: string) => void }) {
const { setQuery } = props
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
function GroupLeaderboard(props: {
topUsers: { user: User; score: number }[]
title: string
maxToShow: number
header: string
maxToShow?: number
}) {
const { users, scoreFunction, title, header, maxToShow } = props
const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a))
const { topUsers, title, maxToShow, header } = props
const scoresByUser = topUsers.reduce((acc, { user, score }) => {
acc[user.id] = score
return acc
}, {} as { [key: string]: number })
return (
<Leaderboard
className="max-w-xl"
users={sortedUsers}
users={topUsers.map((t) => t.user)}
title={title}
columns={[
{ header, renderCell: (user) => formatMoney(scoreFunction(user)) },
{ header, renderCell: (user) => formatMoney(scoresByUser[user.id]) },
]}
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 }) {
const { group, user } = props
const [open, setOpen] = useState(false)
@ -684,3 +534,15 @@ function JoinGroupButton(props: {
</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)

View File

@ -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 Router from 'next/router'
import Router, { useRouter } from 'next/router'
import { Notification, notification_source_types } from 'common/notification'
import { Avatar, EmptyAvatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row'
@ -26,6 +26,7 @@ import {
import {
NotificationGroup,
useGroupedNotifications,
useUnseenGroupedNotification,
} from 'web/hooks/use-notifications'
import { TrendingUpIcon } from '@heroicons/react/outline'
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 { safeLocalStorage } from 'web/lib/util/local'
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 { usePrivateUser, useUser } from 'web/hooks/use-user'
import { UserLink } from 'web/components/user-link'
@ -56,24 +57,51 @@ const HIGHLIGHT_CLASS = 'bg-indigo-50'
export default function Notifications() {
const privateUser = usePrivateUser()
const router = useRouter()
const [navigateToSection, setNavigateToSection] = useState<string>()
const [activeIndex, setActiveIndex] = useState(0)
useEffect(() => {
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 (
<Page>
<div className={'px-2 pt-4 sm:px-4 lg:pt-0'}>
<Title text={'Notifications'} className={'hidden md:block'} />
<SEO title="Notifications" description="Manifold user notifications" />
{privateUser && (
{privateUser && router.isReady && (
<div>
<Tabs
<ControlledTabs
currentPageForAnalytics={'notifications'}
labelClassName={'pb-2 pt-1 '}
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={[
{
title: 'Notifications',
@ -82,9 +110,9 @@ export default function Notifications() {
{
title: 'Settings',
content: (
<div className={''}>
<NotificationSettings />
</div>
<NotificationSettings
navigateToSection={navigateToSection}
/>
),
},
]}
@ -128,16 +156,13 @@ function NotificationsList(props: { privateUser: PrivateUser }) {
const { privateUser } = props
const [page, setPage] = useState(0)
const allGroupedNotifications = useGroupedNotifications(privateUser)
const unseenGroupedNotifications = useUnseenGroupedNotification(privateUser)
const paginatedGroupedNotifications = useMemo(() => {
if (!allGroupedNotifications) return
const start = page * NOTIFICATIONS_PER_PAGE
const end = start + NOTIFICATIONS_PER_PAGE
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()
local?.setItem(
'notification-groups',
@ -146,6 +171,19 @@ function NotificationsList(props: { privateUser: PrivateUser }) {
return maxNotificationsToShow
}, [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)
return <LoadingIndicator />
@ -992,51 +1030,54 @@ function getReasonForShowingNotification(
) {
const { sourceType, sourceUpdateType, reason, sourceSlug } = notification
let reasonText: string
switch (sourceType) {
case 'comment':
if (reason === 'reply_to_users_answer')
reasonText = justSummary ? 'replied' : 'replied to you on'
else if (reason === 'tagged_user')
reasonText = justSummary ? 'tagged you' : 'tagged you on'
else if (reason === 'reply_to_users_comment')
reasonText = justSummary ? 'replied' : 'replied to you on'
else reasonText = justSummary ? `commented` : `commented on`
break
case 'contract':
if (reason === 'you_follow_user')
reasonText = justSummary ? 'asked the question' : 'asked'
else if (sourceUpdateType === 'resolved')
reasonText = justSummary ? `resolved the question` : `resolved`
else if (sourceUpdateType === 'closed') reasonText = `Please resolve`
else reasonText = justSummary ? 'updated the question' : `updated`
break
case 'answer':
if (reason === 'on_users_contract') reasonText = `answered your question `
else reasonText = `answered`
break
case 'follow':
reasonText = 'followed you'
break
case 'liquidity':
reasonText = 'added a subsidy to your question'
break
case 'group':
reasonText = 'added you to the group'
break
case 'user':
if (sourceSlug && reason === 'user_joined_to_bet_on_your_market')
reasonText = 'joined to bet on your market'
else if (sourceSlug) reasonText = 'joined because you shared'
else reasonText = 'joined because of you'
break
case 'bet':
reasonText = 'bet against you'
break
case 'challenge':
reasonText = 'accepted your challenge'
break
default:
reasonText = ''
}
// TODO: we could leave out this switch and just use the reason field now that they have more information
if (reason === 'tagged_user')
reasonText = justSummary ? 'tagged you' : 'tagged you on'
else
switch (sourceType) {
case 'comment':
if (reason === 'reply_to_users_answer')
reasonText = justSummary ? 'replied' : 'replied to you on'
else if (reason === 'reply_to_users_comment')
reasonText = justSummary ? 'replied' : 'replied to you on'
else reasonText = justSummary ? `commented` : `commented on`
break
case 'contract':
if (reason === 'contract_from_followed_user')
reasonText = justSummary ? 'asked the question' : 'asked'
else if (sourceUpdateType === 'resolved')
reasonText = justSummary ? `resolved the question` : `resolved`
else if (sourceUpdateType === 'closed') reasonText = `Please resolve`
else reasonText = justSummary ? 'updated the question' : `updated`
break
case 'answer':
if (reason === 'answer_on_your_contract')
reasonText = `answered your question `
else reasonText = `answered`
break
case 'follow':
reasonText = 'followed you'
break
case 'liquidity':
reasonText = 'added a subsidy to your question'
break
case 'group':
reasonText = 'added you to the group'
break
case 'user':
if (sourceSlug && reason === 'user_joined_to_bet_on_your_market')
reasonText = 'joined to bet on your market'
else if (sourceSlug) reasonText = 'joined because you shared'
else reasonText = 'joined because of you'
break
case 'bet':
reasonText = 'bet against you'
break
case 'challenge':
reasonText = 'accepted your challenge'
break
default:
reasonText = ''
}
return reasonText
}

View File

@ -69,10 +69,9 @@ export default function PostPage(props: {
return (
<Page>
<div className="mx-auto w-full max-w-3xl ">
<Spacer h={1} />
<Title className="!mt-0" text={post.title} />
<Title className="!mt-0 py-4 px-2" text={post.title} />
<Row>
<Col className="flex-1">
<Col className="flex-1 px-2">
<div className={'inline-flex'}>
<div className="mr-1 text-gray-500">Created by</div>
<UserLink
@ -82,7 +81,7 @@ export default function PostPage(props: {
/>
</div>
</Col>
<Col>
<Col className="px-2">
<Button
size="lg"
color="gray-white"
@ -116,7 +115,7 @@ export default function PostPage(props: {
</div>
</div>
<Spacer h={2} />
<Spacer h={4} />
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
<PostCommentsActivity
post={post}
@ -145,7 +144,7 @@ export function PostCommentsActivity(props: {
)
return (
<>
<Col className="p-2">
<PostCommentInput post={post} />
{topLevelComments.map((parent) => (
<PostCommentThread
@ -161,7 +160,7 @@ export function PostCommentsActivity(props: {
commentsByUserId={commentsByUserId}
/>
))}
</>
</Col>
)
}

View File

@ -77,13 +77,21 @@ const Salem = {
const tourneys: Tourney[] = [
{
title: 'Cause Exploration Prizes',
title: 'Manifold F2P Tournament',
blurb:
'Which new charity ideas will Open Philanthropy find most promising?',
award: 'M$100k',
endTime: toDate('Sep 9, 2022'),
groupId: 'cMcpBQ2p452jEcJD2SFw',
'Who can amass the most mana starting from a free-to-play (F2P) account?',
award: 'Poem',
endTime: toDate('Sep 15, 2022'),
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',
blurb: 'How many points will each NFL player score this season?',
@ -91,13 +99,6 @@ const tourneys: Tourney[] = [
endTime: toDate('Jan 6, 2023'),
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',
// blurb: 'Something amazing',
@ -105,6 +106,27 @@ const tourneys: Tourney[] = [
// endTime: toDate('Sep 22, 2022'),
// 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 = {
@ -135,20 +157,23 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) {
title="Tournaments"
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%]">
{sections.map(({ tourney, slug, numPeople }) => (
<div key={slug}>
<SectionHeader
url={groupPath(slug)}
title={tourney.title}
ppl={numPeople}
award={tourney.award}
endTime={tourney.endTime}
/>
<span>{tourney.blurb}</span>
<MarketCarousel slug={slug} />
</div>
))}
<Col className="m-4 gap-10 sm:mx-10 sm:gap-24 xl:w-[125%]">
{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>
)
)}
<div>
<SectionHeader
url={Salem.url}
@ -156,9 +181,52 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) {
award={Salem.award}
endTime={Salem.endTime}
/>
<span>{Salem.blurb}</span>
<span className="text-gray-500">{Salem.blurb}</span>
<ImageCarousel url={Salem.url} images={Salem.images} />
</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>
</Page>
)
@ -175,9 +243,7 @@ const SectionHeader = (props: {
return (
<Link href={url}>
<a className="group mb-3 flex flex-wrap justify-between">
<h2 className="text-xl font-semibold group-hover:underline md:text-3xl">
{title}
</h2>
<h2 className="text-xl group-hover:underline md:text-3xl">{title}</h2>
<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>}
{!!ppl && (

View File

@ -64,6 +64,8 @@ function putIntoMapAndFetch(data) {
document.getElementById('guess-type').innerText = 'Finding Fantastic Beasts'
} else if (whichGuesser === 'basic') {
document.getElementById('guess-type').innerText = 'How Basic'
} else if (whichGuesser === 'commander') {
document.getElementById('guess-type').innerText = 'General Knowledge'
}
setUpNewGame()
}
@ -156,8 +158,8 @@ function determineIfSkip(card) {
if (card.flavor_name) {
return true
}
// don't include racist cards
return card.content_warning
return false
}
function putIntoMap(data) {

View File

@ -3,16 +3,16 @@ import requests
import json
# add category name here
allCategories = ['counterspell', 'beast', 'burn'] #, 'terror', 'wrath']
allCategories = ['counterspell', 'beast', 'burn', 'commander', 'artist'] #, 'terror', 'wrath', 'zombie', 'artifact']
specialCategories = ['set', 'basic']
def generate_initial_query(category):
string_query = 'https://api.scryfall.com/cards/search?q='
if category == 'counterspell':
string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure'
string_query += 'otag%3Acounterspell+t%3Ainstant+not%3Aadventure+not%3Adfc'
elif category == 'beast':
string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken'
string_query += '-type%3Alegendary+type%3Abeast+-type%3Atoken+not%3Adfc'
# elif category == 'terror':
# string_query += 'otag%3Acreature-removal+o%3A%2Fdestroy+target.%2A+%28creature%7Cpermanent%29%2F+%28t' \
# '%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' \
'%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' \
'+%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
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' \
'%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='
print(string_query)
return string_query
@ -89,22 +97,35 @@ def fetch_special(query):
def to_compact_write_form(smallJson, art_names, response, category):
fieldsInCard = ['name', 'image_uris', 'content_warning', 'flavor_name', 'reprint', 'frame_effects', 'digital',
'set_type']
fieldsInCard = ['name', 'image_uris', 'flavor_name', 'reprint', 'frame_effects', 'digital', 'set_type']
data = []
# write all fields needed in card
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
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
else:
art_names.add(card['illustration_id'])
write_card = dict()
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:
write_card['name'] = card['card_faces'][0]['name']
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:
write_card[field] = card[field]
data.append(write_card)
@ -115,6 +136,9 @@ def to_compact_write_form_special(smallJson, art_names, response, category):
data = []
# write all fields needed in card
for card in response['data']:
# do not include racist cards
if 'content_warning' in card and card['content_warning'] == True:
continue
if category == 'basic':
write_card = dict()
# do not repeat art
@ -152,9 +176,9 @@ def write_image_uris(card_image_uris):
if __name__ == "__main__":
# for category in allCategories:
# print(category)
# fetch_and_write_all(category, generate_initial_query(category))
for category in allCategories:
print(category)
fetch_and_write_all(category, generate_initial_query(category))
for category in specialCategories:
print(category)
fetch_and_write_all_special(category, generate_initial_special_query(category))

View File

@ -17,6 +17,14 @@
f.parentNode.insertBefore(j, f)
})(window, document, 'script', 'dataLayer', 'GTM-M3MBVGG')
</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 -->
<meta charset="UTF-8" />
<style type="text/css">
@ -105,6 +113,18 @@
list-style: none;
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>
</head>
<body>
@ -125,48 +145,115 @@
action="guess.html"
style="display: flex; flex-direction: column; align-items: center"
>
<input
type="radio"
id="counterspell"
name="whichguesser"
value="counterspell"
checked
/>
<label class="radio-label" for="counterspell">
<img
class="thumbnail"
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/7/1/71cfcba5-1571-48b8-a3db-55dca135506e.jpg?1562843855"
<div class="option-row">
<input
type="radio"
id="counterspell"
name="whichguesser"
value="counterspell"
onchange="updateSettingDefault(true, true, false)"
checked
/>
<h3>Counterspell Guesser</h3></label
><br />
<input type="radio" id="burn" name="whichguesser" value="burn" />
<label class="radio-label" for="burn">
<label class="radio-label" for="counterspell">
<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
>
<img
class="thumbnail"
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/6/0/60b2fae1-242b-45e0-a757-b1adc02c06f3.jpg?1562760596"
class="level-badge"
src="https://static.wikia.nocookie.net/mtgsalvation_gamepedia/images/a/a8/Advanced_level.jpg"
/>
<h3>Match With Hot Singles</h3></label
><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
>
</div>
<br />
<input type="radio" id="basic" name="whichguesser" value="basic" />
<label class="radio-label" for="basic">
<img
class="thumbnail"
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/0/3/03683fbb-9843-4c14-bb95-387150e97c90.jpg?1642161346"
<div class="option-row">
<input
type="radio"
id="burn"
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 />
<details id="addl-options">

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

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

View File

@ -60,6 +60,18 @@ module.exports = {
'overflow-wrap': 'anywhere',
'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',
},
},
})
}),
],