Merge branch 'main' into new-home-3

This commit is contained in:
James Grugett 2022-09-14 14:51:27 -05:00
commit 47f758bba8
96 changed files with 4881 additions and 2069 deletions

View File

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

View File

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

View File

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

View File

@ -13,5 +13,5 @@ export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_AMOUNT =
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5

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

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

View File

@ -1,3 +1,6 @@
import { notification_subscription_types, PrivateUser } from './user'
import { DOMAIN } from './envs/constants'
export type Notification = {
id: string
userId: string
@ -15,7 +18,7 @@ export type Notification = {
sourceUserUsername?: string
sourceUserAvatarUrl?: string
sourceText?: string
data?: string
data?: { [key: string]: any }
sourceContractTitle?: string
sourceContractCreatorUsername?: string
@ -51,28 +54,113 @@ 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.notificationPreferences
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]
: []
}
// const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
return {
sendToEmail: destinations.includes('email'),
sendToBrowser: destinations.includes('browser'),
// unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
}
}
export type BettingStreakData = {
streak: number
bonusAmount: number
}

View File

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

View File

@ -1,3 +1,5 @@
import { filterDefined } from './util/array'
export type User = {
id: string
createdTime: number
@ -63,9 +65,63 @@ export type PrivateUser = {
initialDeviceToken?: string
initialIpAddress?: string
apiKey?: string
notificationPreferences?: notification_subscribe_types
notificationPreferences: notification_subscription_types
twitchInfo?: {
twitchName: string
controlToken: string
botEnabled?: boolean
}
}
export type notification_destination_types = 'email' | 'browser'
export type notification_subscription_types = {
// Watched Markets
all_comments_on_watched_markets: notification_destination_types[]
all_answers_on_watched_markets: notification_destination_types[]
// Comments
tipped_comments_on_watched_markets: notification_destination_types[]
comments_by_followed_users_on_watched_markets: notification_destination_types[]
all_replies_to_my_comments_on_watched_markets: notification_destination_types[]
all_replies_to_my_answers_on_watched_markets: notification_destination_types[]
all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
// Answers
answers_by_followed_users_on_watched_markets: notification_destination_types[]
answers_by_market_creator_on_watched_markets: notification_destination_types[]
all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
// On users' markets
your_contract_closed: notification_destination_types[]
all_comments_on_my_markets: notification_destination_types[]
all_answers_on_my_markets: notification_destination_types[]
subsidized_your_market: notification_destination_types[]
// Market updates
resolutions_on_watched_markets: notification_destination_types[]
resolutions_on_watched_markets_with_shares_in: notification_destination_types[]
market_updates_on_watched_markets: notification_destination_types[]
market_updates_on_watched_markets_with_shares_in: notification_destination_types[]
probability_updates_on_watched_markets: notification_destination_types[]
// Balance Changes
loan_income: notification_destination_types[]
betting_streaks: notification_destination_types[]
referral_bonuses: notification_destination_types[]
unique_bettors_on_your_contract: notification_destination_types[]
tips_on_your_comments: notification_destination_types[]
tips_on_your_markets: notification_destination_types[]
limit_order_fills: notification_destination_types[]
// General
tagged_user: notification_destination_types[]
on_new_follow: notification_destination_types[]
contract_from_followed_user: notification_destination_types[]
trending_markets: notification_destination_types[]
profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[]
thank_you_for_purchases: notification_destination_types[]
}
export type notification_subscribe_types = 'all' | 'less' | 'none'
export type PortfolioMetrics = {
@ -78,3 +134,122 @@ 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 {
unsubscribedFromCommentEmails,
unsubscribedFromAnswerEmails,
unsubscribedFromResolutionEmails,
unsubscribedFromWeeklyTrendingEmails,
unsubscribedFromGenericEmails,
} = privateUser || {}
const constructPref = (browserIf: boolean, emailIf: boolean) => {
const browser = browserIf ? 'browser' : undefined
const email = noEmails ? undefined : emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_destination_types[]
}
return {
// Watched Markets
all_comments_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
all_answers_on_watched_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
// Comments
tips_on_your_comments: constructPref(true, !unsubscribedFromCommentEmails),
comments_by_followed_users_on_watched_markets: constructPref(true, false),
all_replies_to_my_comments_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
all_replies_to_my_answers_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
// Answers
answers_by_followed_users_on_watched_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
answers_by_market_creator_on_watched_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
// On users' markets
your_contract_closed: constructPref(
true,
!unsubscribedFromResolutionEmails
), // High priority
all_comments_on_my_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
all_answers_on_my_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
subsidized_your_market: constructPref(true, true),
// Market updates
resolutions_on_watched_markets: constructPref(
true,
!unsubscribedFromResolutionEmails
),
market_updates_on_watched_markets: constructPref(true, false),
market_updates_on_watched_markets_with_shares_in: constructPref(
true,
false
),
resolutions_on_watched_markets_with_shares_in: constructPref(
true,
!unsubscribedFromResolutionEmails
),
//Balance Changes
loan_income: constructPref(true, false),
betting_streaks: constructPref(true, false),
referral_bonuses: constructPref(true, true),
unique_bettors_on_your_contract: constructPref(true, false),
tipped_comments_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
tips_on_your_markets: constructPref(true, true),
limit_order_fills: constructPref(true, false),
// General
tagged_user: constructPref(true, true),
on_new_follow: constructPref(true, true),
contract_from_followed_user: constructPref(true, true),
trending_markets: constructPref(
false,
!unsubscribedFromWeeklyTrendingEmails
),
profit_loss_updates: constructPref(false, true),
probability_updates_on_watched_markets: constructPref(true, false),
thank_you_for_purchases: constructPref(
false,
!unsubscribedFromGenericEmails
),
onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails),
} as notification_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

@ -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', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]);
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'unsubscribedFromWeeklyTrendingEmails', 'notificationPreferences', 'twitchInfo']);
}
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

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

View File

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

View File

@ -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({

File diff suppressed because it is too large Load Diff

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,
notificationPreferences: 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

@ -491,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

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

View File

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

View File

@ -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>
</html>
</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,17 @@ 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'
import { Dictionary } from 'lodash'
export const sendMarketResolutionEmail = async (
userId: string,
reason: notification_reason_types,
privateUser: PrivateUser,
investment: number,
payout: number,
creator: User,
@ -32,15 +36,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 +53,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 +81,7 @@ export const sendMarketResolutionEmail = async (
return await sendTemplateEmail(
privateUser.email,
subject,
'market-resolved',
correctedInvestment === 0 ? 'market-resolved-no-bets' : 'market-resolved',
templateData
)
}
@ -154,11 +150,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 +163,7 @@ export const sendWelcomeEmail = async (
'welcome',
{
name: firstName,
unsubscribeLink,
unsubscribeUrl,
},
{
from: 'David from Manifold <david@manifold.markets>',
@ -217,23 +214,23 @@ export const sendOneWeekBonusEmail = async (
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromGenericEmails
!privateUser.notificationPreferences.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 +247,23 @@ export const sendCreatorGuideEmail = async (
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromGenericEmails
!privateUser.notificationPreferences.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 +279,18 @@ export const sendThankYouEmail = async (
if (
!privateUser ||
!privateUser.email ||
privateUser.unsubscribedFromGenericEmails
!privateUser.notificationPreferences.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 +298,7 @@ export const sendThankYouEmail = async (
'thank-you',
{
name: firstName,
unsubscribeLink,
unsubscribeUrl,
},
{
from: 'David from Manifold <david@manifold.markets>',
@ -307,16 +307,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 +323,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 +340,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 +371,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 +382,7 @@ export const sendNewCommentEmail = async (
answerNumber,
commentorName,
commentorAvatarUrl: commentorAvatarUrl ?? '',
comment: text,
comment: commentText,
marketUrl,
unsubscribeUrl,
betDescription,
@ -412,7 +403,7 @@ export const sendNewCommentEmail = async (
{
commentorName,
commentorAvatarUrl: commentorAvatarUrl ?? '',
comment: text,
comment: commentText,
marketUrl,
unsubscribeUrl,
betDescription,
@ -423,29 +414,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 +460,13 @@ export const sendInterestingMarketsEmail = async (
if (
!privateUser ||
!privateUser.email ||
privateUser?.unsubscribedFromWeeklyTrendingEmails
!privateUser.notificationPreferences.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 +477,7 @@ export const sendInterestingMarketsEmail = async (
'interesting-markets',
{
name: firstName,
unsubscribeLink: unsubscribeUrl,
unsubscribeUrl,
question1Title: contractsToSend[0].question,
question1Link: contractUrl(contractsToSend[0]),
@ -522,3 +509,97 @@ 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>`,
}
)
}
export const sendNewUniqueBettorsEmail = async (
reason: notification_reason_types,
userId: string,
privateUser: PrivateUser,
contract: Contract,
totalPredictors: number,
newPredictors: User[],
userBets: Dictionary<[Bet, ...Bet[]]>,
bonusAmount: number
) => {
const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
await getDestinationsForUser(privateUser, reason)
if (!privateUser.email || !sendToEmail) return
const user = await getUser(privateUser.id)
if (!user) return
const { name } = user
const firstName = name.split(' ')[0]
const creatorName = contract.creatorName
// make the emails stack for the same contract
const subject = `You made a popular market! ${
contract.question.length > 50
? contract.question.slice(0, 50) + '...'
: contract.question
} just got ${
newPredictors.length
} new predictions. Check out who's predicting on it inside.`
const templateData: Record<string, string> = {
name: firstName,
creatorName,
totalPredictors: totalPredictors.toString(),
bonusString: formatMoney(bonusAmount),
marketTitle: contract.question,
marketUrl: contractUrl(contract),
unsubscribeUrl,
newPredictors: newPredictors.length.toString(),
}
newPredictors.forEach((p, i) => {
templateData[`bettor${i + 1}Name`] = p.name
if (p.avatarUrl) templateData[`bettor${i + 1}AvatarUrl`] = p.avatarUrl
const bet = userBets[p.id][0]
if (bet) {
const { amount, sale } = bet
templateData[`bet${i + 1}Description`] = `${
sale || amount < 0 ? 'sold' : 'bought'
} ${formatMoney(Math.abs(amount))}`
}
})
return await sendTemplateEmail(
privateUser.email,
subject,
newPredictors.length === 1 ? 'new-unique-bettor' : 'new-unique-bettors',
templateData,
{
from: `Manifold Markets <no-reply@manifold.markets>`,
}
)
}

View File

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

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

@ -24,12 +24,16 @@ import {
} from '../../common/antes'
import { APIError } from '../../common/api'
import { User } from '../../common/user'
import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes'
import { addHouseLiquidity } from './add-liquidity'
import { DAY_MS } from '../../common/util/time'
const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
export const onCreateBet = functions.firestore
.document('contracts/{contractId}/bets/{betId}')
export const onCreateBet = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
.firestore.document('contracts/{contractId}/bets/{betId}')
.onCreate(async (change, context) => {
const { contractId } = context.params as {
contractId: string
@ -58,6 +62,12 @@ export const onCreateBet = functions.firestore
const bettor = await getUser(bet.userId)
if (!bettor) return
await change.ref.update({
userAvatarUrl: bettor.avatarUrl,
userName: bettor.name,
userUsername: bettor.username,
})
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor)
await notifyFills(bet, contract, eventId, bettor)
await updateBettingStreak(bettor, bet, contract, eventId)
@ -71,12 +81,16 @@ const updateBettingStreak = async (
contract: Contract,
eventId: string
) => {
const betStreakResetTime = getTodaysBettingStreakResetTime()
const now = Date.now()
const currentDateResetTime = currentDateBettingStreakResetTime()
// if now is before reset time, use yesterday's reset time
const lastDateResetTime = currentDateResetTime - DAY_MS
const betStreakResetTime =
now < currentDateResetTime ? lastDateResetTime : currentDateResetTime
const lastBetTime = user?.lastBetTime ?? 0
// If they've already bet after the reset time, or if we haven't hit the reset time yet
if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime)
return
// If they've already bet after the reset time
if (lastBetTime > betStreakResetTime) return
const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1
// Otherwise, add 1 to their betting streak
@ -119,6 +133,7 @@ const updateBettingStreak = async (
bet,
contract,
bonusAmount,
newBettingStreak,
eventId
)
}
@ -148,12 +163,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
}
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
// Update contract unique bettors
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await firestore.collection(`contracts`).doc(contract.id).update({
uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length,
@ -163,6 +179,10 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
// No need to give a bonus for the creator's bet
if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
if (contract.mechanism === 'cpmm-1') {
await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT)
}
// Create combined txn for all new unique bettors
const bonusTxnDetails = {
contractId: contract.id,
@ -198,6 +218,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
result.txn.id,
contract,
result.txn.amount,
newUniqueBettorIds,
eventId + '-unique-bettor-bonus'
)
}
@ -244,6 +265,6 @@ const notifyFills = async (
)
}
const getTodaysBettingStreakResetTime = () => {
const currentDateBettingStreakResetTime = () => {
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
}

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

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

View File

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

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

View File

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

View File

@ -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({
notificationPreferences: 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,
notificationPreferences: getDefaultNotificationSettings(user.id),
}
if (user.totalDeposits === undefined) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

@ -42,6 +42,7 @@ import { YesNoSelector } from './yes-no-selector'
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
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
@ -90,10 +91,7 @@ export function BetPanel(props: {
/>
</>
) : (
<>
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</>
<MarketIntroPanel />
)}
</Col>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,9 +14,7 @@ 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'
@ -301,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

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

View File

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

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

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

View File

@ -0,0 +1,27 @@
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={125}
width={125}
className="my-4 self-center"
src="/welcome/manipurple.png"
alt="Manifold Markets gradient logo"
/>
<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,350 @@
import React, { memo, ReactNode, useEffect, useState } from 'react'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import {
notification_subscription_types,
notification_destination_types,
PrivateUser,
} from 'common/user'
import { updatePrivateUser } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col'
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 toast from 'react-hot-toast'
import { SwitchSetting } from 'web/components/switch-setting'
import { uniq } from 'lodash'
import {
storageStore,
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
export function NotificationSettings(props: {
navigateToSection: string | undefined
privateUser: PrivateUser
}) {
const { navigateToSection, privateUser } = props
const [showWatchModal, setShowWatchModal] = useState(false)
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',
'unique_bettors_on_your_contract',
// TODO: add these
// one-click unsubscribe only unsubscribes them from that type only, (well highlighted), then a link to manage the rest of their notifications
// 'profit_loss_updates', - changes in markets you have shares in
// biggest winner, here are the rest of your markets
// 'referral_bonuses',
// 'on_new_follow',
// '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: 'Prediction 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',
},
}
function NotificationSettingLine(props: {
description: string
subscriptionTypeKey: keyof notification_subscription_types
destinations: notification_destination_types[]
}) {
const { description, subscriptionTypeKey, destinations } = props
const previousInAppValue = destinations.includes('browser')
const previousEmailValue = destinations.includes('email')
const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue)
const [emailEnabled, setEmailEnabled] = useState(previousEmailValue)
const loading = 'Changing Notifications Settings'
const success = 'Changed Notification Settings!'
const highlight = navigateToSection === subscriptionTypeKey
const changeSetting = (setting: 'browser' | 'email', newValue: boolean) => {
toast
.promise(
updatePrivateUser(privateUser.id, {
notificationPreferences: {
...privateUser.notificationPreferences,
[subscriptionTypeKey]: destinations.includes(setting)
? destinations.filter((d) => d !== setting)
: uniq([...destinations, setting]),
},
}),
{
success,
loading,
error: 'Error changing notification settings. Try again?',
}
)
.then(() => {
if (setting === 'browser') {
setInAppEnabled(newValue)
} else {
setEmailEnabled(newValue)
}
})
}
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(subscriptionTypeKey) && (
<SwitchSetting
checked={inAppEnabled}
onChange={(newVal) => changeSetting('browser', newVal)}
label={'Web'}
/>
)}
{emailsEnabled.includes(subscriptionTypeKey) && (
<SwitchSetting
checked={emailEnabled}
onChange={(newVal) => changeSetting('email', newVal)}
label={'Email'}
/>
)}
</Row>
</Col>
</Row>
)
}
const getUsersSavedPreference = (
key: keyof notification_subscription_types
) => {
return privateUser.notificationPreferences[key] ?? []
}
const Section = memo(function Section(props: {
icon: ReactNode
data: SectionData
}) {
const { icon, data } = props
const { label, subscriptionTypeToDescription } = data
const expand =
navigateToSection &&
Object.keys(subscriptionTypeToDescription).includes(navigateToSection)
// Not sure how to prevent re-render (and collapse of an open section)
// due to a private user settings change. Just going to persist expanded state here
const [expanded, setExpanded] = usePersistentState(expand ?? false, {
key:
'NotificationsSettingsSection-' +
Object.keys(subscriptionTypeToDescription).join('-'),
store: storageStore(safeLocalStorage()),
})
// Not working as the default value for expanded, so using a useEffect
useEffect(() => {
if (expand) setExpanded(true)
}, [expand, setExpanded])
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
subscriptionTypeKey={key as keyof notification_subscription_types}
destinations={getUsersSavedPreference(
key as keyof notification_subscription_types
)}
description={value}
/>
))}
</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 icon={<ChatIcon className={'h-6 w-6'} />} data={comments} />
<Section
icon={<TrendingUpIcon className={'h-6 w-6'} />}
data={updates}
/>
<Section
icon={<LightBulbIcon className={'h-6 w-6'} />}
data={answers}
/>
<Section icon={<UserIcon className={'h-6 w-6'} />} data={yourMarkets} />
<Row className={'gap-2 text-xl text-gray-700'}>
<span>Balance Changes</span>
</Row>
<Section
icon={<CurrencyDollarIcon className={'h-6 w-6'} />}
data={bonuses}
/>
<Section
icon={<CashIcon className={'h-6 w-6'} />}
data={otherBalances}
/>
<Row className={'gap-2 text-xl text-gray-700'}>
<span>General</span>
</Row>
<Section
icon={<UsersIcon className={'h-6 w-6'} />}
data={userInteractions}
/>
<Section
icon={<InboxInIcon className={'h-6 w-6'} />}
data={generalOther}
/>
<WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} />
</Col>
</div>
)
}

View File

@ -3,26 +3,52 @@ import { Col } from 'web/components/layout/col'
import {
BETTING_STREAK_BONUS_AMOUNT,
BETTING_STREAK_BONUS_MAX,
BETTING_STREAK_RESET_HOUR,
} from 'common/economy'
import { formatMoney } from 'common/util/format'
import { User } from 'common/user'
import dayjs from 'dayjs'
import clsx from 'clsx'
export function BettingStreakModal(props: {
isOpen: boolean
setOpen: (open: boolean) => void
currentUser?: User | null
}) {
const { isOpen, setOpen } = props
const { isOpen, setOpen, currentUser } = props
const missingStreak = currentUser && !hasCompletedStreakToday(currentUser)
return (
<Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🔥</span>
<span className="text-xl">Daily betting streaks</span>
<span
className={clsx(
'text-8xl',
missingStreak ? 'grayscale' : 'grayscale-0'
)}
>
🔥
</span>
{missingStreak && (
<Col className={' gap-2 text-center'}>
<span className={'font-bold'}>
You haven't predicted yet today!
</span>
<span className={'ml-2'}>
If the fire icon is gray, this means you haven't predicted yet
today to get your streak bonus. Get out there and make a
prediction!
</span>
</Col>
)}
<span className="text-xl">Daily prediction streaks</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are they?</span>
<span className={'ml-2'}>
You get {formatMoney(BETTING_STREAK_BONUS_AMOUNT)} more for each day
of consecutive betting up to {formatMoney(BETTING_STREAK_BONUS_MAX)}
. The more days you bet in a row, the more you earn!
of consecutive predicting up to{' '}
{formatMoney(BETTING_STREAK_BONUS_MAX)}. The more days you predict
in a row, the more you earn!
</span>
<span className={'text-indigo-700'}>
Where can I check my streak?
@ -36,3 +62,17 @@ export function BettingStreakModal(props: {
</Modal>
)
}
export function hasCompletedStreakToday(user: User) {
const now = dayjs().utc()
const utcTodayAtResetHour = now
.hour(BETTING_STREAK_RESET_HOUR)
.minute(0)
.second(0)
const utcYesterdayAtResetHour = utcTodayAtResetHour.subtract(1, 'day')
let resetTime = utcTodayAtResetHour.valueOf()
if (now.isBefore(utcTodayAtResetHour)) {
resetTime = utcYesterdayAtResetHour.valueOf()
}
return (user?.lastBetTime ?? 0) > resetTime
}

View File

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

View File

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

@ -28,7 +28,10 @@ import { ReferralsButton } from 'web/components/referrals-button'
import { formatMoney } from 'common/util/format'
import { ShareIconButton } from 'web/components/share-icon-button'
import { ENV_CONFIG } from 'common/envs/constants'
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
import {
BettingStreakModal,
hasCompletedStreakToday,
} from 'web/components/profile/betting-streak-modal'
import { REFERRAL_AMOUNT } from 'common/economy'
import { LoansModal } from './profile/loans-modal'
import { UserLikesButton } from 'web/components/profile/user-likes-button'
@ -83,6 +86,7 @@ export function UserPage(props: { user: User }) {
<BettingStreakModal
isOpen={showBettingStreakModal}
setOpen={setShowBettingStreakModal}
currentUser={currentUser}
/>
{showLoansModal && (
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
@ -139,7 +143,12 @@ export function UserPage(props: { user: User }) {
<span>profit</span>
</Col>
<Col
className={'cursor-pointer items-center text-gray-500'}
className={clsx(
'cursor-pointer items-center text-gray-500',
isCurrentUser && !hasCompletedStreakToday(user)
? 'grayscale'
: 'grayscale-0'
)}
onClick={() => setShowBettingStreakModal(true)}
>
<span>🔥 {user.currentBettingStreak ?? 0}</span>

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
}

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

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

View File

@ -25,7 +25,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) =>
@ -254,9 +253,9 @@ 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)
}
export const topFollowedGroupsQuery = query(

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

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

View File

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

View File

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

View File

@ -47,7 +47,11 @@ function DoneButton(props: { className?: string }) {
return (
<SiteLink href="/experimental/home">
<Button size="lg" color="blue" className={clsx(className, 'flex whitespace-nowrap')}>
<Button
size="lg"
color="blue"
className={clsx(className, 'flex whitespace-nowrap')}
>
Done
</Button>
</SiteLink>

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'
@ -31,13 +31,9 @@ import { SEO } from 'web/components/SEO'
import { Linkify } from 'web/components/linkify'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Tabs } from 'web/components/layout/tabs'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { ContractSearch } from 'web/components/contract-search'
import { 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'
@ -53,13 +49,14 @@ import { Spacer } from 'web/components/layout/spacer'
import { usePost } from 'web/hooks/use-post'
import { useAdmin } from 'web/hooks/use-admin'
import { track } from '@amplitude/analytics-browser'
import { SelectMarketsModal } from 'web/components/contract-select-modal'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
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 +68,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 +95,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 +107,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 +136,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 +147,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 +184,7 @@ export default function GroupPage(props: {
creator={creator}
isCreator={!!isCreator}
user={user}
members={members}
memberIds={memberIds}
/>
</Col>
)
@ -233,7 +201,6 @@ export default function GroupPage(props: {
const tabs = [
{
badge: `${contractsCount}`,
title: 'Markets',
content: questionsTab,
href: groupPath(group.slug, 'markets'),
@ -312,9 +279,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 +300,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,179 +366,46 @@ 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)
const [contracts, setContracts] = useState<Contract[]>([])
const [loading, setLoading] = useState(false)
const groupContractIds = useGroupContractIds(group.id)
async function addContractToCurrentGroup(contract: Contract) {
if (contracts.map((c) => c.id).includes(contract.id)) {
setContracts(contracts.filter((c) => c.id !== contract.id))
} else setContracts([...contracts, contract])
}
async function doneAddingContracts() {
Promise.all(
contracts.map(async (contract) => {
setLoading(true)
await addContractToGroup(group, contract, user.id)
})
).then(() => {
setLoading(false)
setOpen(false)
setContracts([])
})
async function onSubmit(contracts: Contract[]) {
await Promise.all(
contracts.map((contract) => addContractToGroup(group, contract, user.id))
)
}
return (
@ -587,71 +421,27 @@ function AddContractButton(props: { group: Group; user: User }) {
</Button>
</div>
<Modal
<SelectMarketsModal
open={open}
setOpen={setOpen}
className={'max-w-4xl sm:p-0'}
size={'xl'}
>
<Col
className={'min-h-screen w-full max-w-4xl gap-4 rounded-md bg-white'}
>
<Col className="p-8 pb-0">
<div className={'text-xl text-indigo-700'}>Add markets</div>
<div className={'text-md my-4 text-gray-600'}>
Add pre-existing markets to this group, or{' '}
<Link href={`/create?groupId=${group.id}`}>
<span className="cursor-pointer font-semibold underline">
create a new one
</span>
</Link>
.
</div>
{contracts.length > 0 && (
<Col className={'w-full '}>
{!loading ? (
<Row className={'justify-end gap-4'}>
<Button onClick={doneAddingContracts} color={'indigo'}>
Add {contracts.length} question
{contracts.length > 1 && 's'}
</Button>
<Button
onClick={() => {
setContracts([])
}}
color={'gray'}
>
Cancel
</Button>
</Row>
) : (
<Row className={'justify-center'}>
<LoadingIndicator />
</Row>
)}
</Col>
)}
</Col>
<div className={'overflow-y-scroll sm:px-8'}>
<ContractSearch
user={user}
hideOrderSelector={true}
onContractClick={addContractToCurrentGroup}
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
additionalFilter={{
excludeContractIds: groupContractIds,
}}
highlightOptions={{
contractIds: contracts.map((c) => c.id),
highlightClassName: '!bg-indigo-100 border-indigo-100 border-2',
}}
/>
title="Add markets"
description={
<div className={'text-md my-4 text-gray-600'}>
Add pre-existing markets to this group, or{' '}
<Link href={`/create?groupId=${group.id}`}>
<span className="cursor-pointer font-semibold underline">
create a new one
</span>
</Link>
.
</div>
</Col>
</Modal>
}
submitLabel={(len) => `Add ${len} question${len !== 1 ? 's' : ''}`}
onSubmit={onSubmit}
contractSearchOptions={{
additionalFilter: { excludeContractIds: groupContractIds },
}}
/>
</>
)
}
@ -684,3 +474,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,10 @@ export default function Notifications() {
{
title: 'Settings',
content: (
<div className={''}>
<NotificationSettings />
</div>
<NotificationSettings
navigateToSection={navigateToSection}
privateUser={privateUser}
/>
),
},
]}
@ -128,16 +157,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 +172,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 />
@ -259,7 +298,7 @@ function IncomeNotificationGroupItem(props: {
...notificationsForSourceTitle[0],
sourceText: sum.toString(),
sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername,
data: JSON.stringify(uniqueUsers),
data: { uniqueUsers },
}
newNotifications.push(newNotification)
}
@ -376,7 +415,7 @@ function IncomeNotificationItem(props: {
const isTip = sourceType === 'tip' || sourceType === 'tip_and_like'
const isUniqueBettorBonus = sourceType === 'bonus'
const userLinks: MultiUserLinkInfo[] =
isTip || isUniqueBettorBonus ? JSON.parse(data ?? '{}') : []
isTip || isUniqueBettorBonus ? data?.uniqueUsers ?? [] : []
useEffect(() => {
setNotificationsAsSeen([notification])
@ -390,7 +429,7 @@ function IncomeNotificationItem(props: {
reasonText = !simple
? `Bonus for ${
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
} new traders on`
} new predictors on`
: 'bonus on'
} else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you on` : `in tips on`
@ -398,19 +437,22 @@ function IncomeNotificationItem(props: {
if (sourceText && +sourceText === 50) reasonText = '(max) for your'
else reasonText = 'for your'
} else if (sourceType === 'loan' && sourceText) {
reasonText = `of your invested bets returned as a`
reasonText = `of your invested predictions returned as a`
// TODO: support just 'like' notification without a tip
} else if (sourceType === 'tip_and_like' && sourceText) {
reasonText = !simple ? `liked` : `in likes on`
}
const streakInDays =
Date.now() - notification.createdTime > 24 * 60 * 60 * 1000
? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT
: user?.currentBettingStreak ?? 0
const streakInDays = notification.data?.streak
? notification.data?.streak
: Date.now() - notification.createdTime > 24 * 60 * 60 * 1000
? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT
: user?.currentBettingStreak ?? 0
const bettingStreakText =
sourceType === 'betting_streak_bonus' &&
(sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak')
(sourceText
? `🔥 ${streakInDays} day Prediction Streak`
: 'Prediction Streak')
return (
<>
@ -508,7 +550,7 @@ function IncomeNotificationItem(props: {
{(isTip || isUniqueBettorBonus) && (
<MultiUserTransactionLink
userInfos={userLinks}
modalLabel={isTip ? 'Who tipped you' : 'Unique traders'}
modalLabel={isTip ? 'Who tipped you' : 'Unique predictors'}
/>
)}
<Row className={'line-clamp-2 flex max-w-xl'}>
@ -992,51 +1034,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

@ -12,15 +12,13 @@ import { uploadImage } from 'web/lib/firebase/storage'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { User, PrivateUser } from 'common/user'
import {
getUserAndPrivateUser,
updateUser,
updatePrivateUser,
} from 'web/lib/firebase/users'
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
import { defaultBannerUrl } from 'web/components/user-page'
import { SiteLink } from 'web/components/site-link'
import Textarea from 'react-expanding-textarea'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { generateNewApiKey } from 'web/lib/api/api-key'
import { TwitchPanel } from 'web/components/profile/twitch-panel'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
@ -96,11 +94,8 @@ export default function ProfilePage(props: {
}
const updateApiKey = async (e: React.MouseEvent) => {
const newApiKey = crypto.randomUUID()
setApiKey(newApiKey)
await updatePrivateUser(user.id, { apiKey: newApiKey }).catch(() => {
setApiKey(privateUser.apiKey || '')
})
const newApiKey = await generateNewApiKey(user.id)
setApiKey(newApiKey ?? '')
e.preventDefault()
}
@ -242,6 +237,8 @@ export default function ProfilePage(props: {
</button>
</div>
</div>
<TwitchPanel />
</Col>
</Col>
</Page>

View File

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

120
web/pages/twitch.tsx Normal file
View File

@ -0,0 +1,120 @@
import { useState } from 'react'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
import { ManifoldLogo } from 'web/components/nav/manifold-logo'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { SEO } from 'web/components/SEO'
import { Spacer } from 'web/components/layout/spacer'
import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import { Row } from 'web/components/layout/row'
import { Button } from 'web/components/button'
import { useTracking } from 'web/hooks/use-tracking'
import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { LoadingIndicator } from 'web/components/loading-indicator'
import toast from 'react-hot-toast'
export default function TwitchLandingPage() {
useSaveReferral()
useTracking('view twitch landing page')
const user = useUser()
const privateUser = usePrivateUser()
const twitchUser = privateUser?.twitchInfo?.twitchName
const callback =
user && privateUser
? () => linkTwitchAccountRedirect(user, privateUser)
: async () => {
const result = await firebaseLogin()
const userId = result.user.uid
const { user, privateUser } = await getUserAndPrivateUser(userId)
if (!user || !privateUser) return
await linkTwitchAccountRedirect(user, privateUser)
}
const [isLoading, setLoading] = useState(false)
const getStarted = async () => {
try {
setLoading(true)
const promise = callback()
track('twitch page button click')
await promise
} catch (e) {
console.error(e)
toast.error('Failed to sign up. Please try again later.')
setLoading(false)
}
}
return (
<Page>
<SEO
title="Manifold Markets on Twitch"
description="Get more out of Twitch with play-money betting markets."
/>
<div className="px-4 pt-2 md:mt-0 lg:hidden">
<ManifoldLogo />
</div>
<Col className="items-center">
<Col className="max-w-3xl">
<Col className="mb-6 rounded-xl sm:m-12 sm:mt-0">
<Row className="self-center">
<img height={200} width={200} src="/twitch-logo.png" />
<img height={200} width={200} src="/flappy-logo.gif" />
</Row>
<div className="m-4 max-w-[550px] self-center">
<h1 className="text-3xl sm:text-6xl xl:text-6xl">
<div className="font-semibold sm:mb-2">
<span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent">
Bet
</span>{' '}
on your favorite streams
</div>
</h1>
<Spacer h={6} />
<div className="mb-4 px-2 ">
Get more out of Twitch with play-money betting markets.{' '}
{!twitchUser &&
'Click the button below to link your Twitch account.'}
<br />
</div>
</div>
<Spacer h={6} />
{twitchUser ? (
<div className="mt-3 self-center rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 ">
<div className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6">
<div className="truncate text-sm font-medium text-gray-500">
Twitch account linked
</div>
<div className="mt-1 text-2xl font-semibold text-gray-900">
{twitchUser}
</div>
</div>
</div>
) : isLoading ? (
<LoadingIndicator spinnerClassName="!w-16 !h-16" />
) : (
<Button
size="2xl"
color="gradient"
className="self-center"
onClick={getStarted}
>
Get started
</Button>
)}
</Col>
</Col>
</Col>
</Page>
)
}

BIN
web/public/twitch-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB