Merge branch 'main' into new-home-3

This commit is contained in:
James Grugett 2022-09-16 11:17:20 -05:00
commit 52cd4e4078
98 changed files with 3005 additions and 1684 deletions

View File

@ -142,17 +142,20 @@ function getCpmmInvested(yourBets: Bet[]) {
const { outcome, shares, amount } = bet
if (floatingEqual(shares, 0)) continue
const spent = totalSpent[outcome] ?? 0
const position = totalShares[outcome] ?? 0
if (amount > 0) {
totalShares[outcome] = (totalShares[outcome] ?? 0) + shares
totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount
totalShares[outcome] = position + shares
totalSpent[outcome] = spent + amount
} else if (amount < 0) {
const averagePrice = totalSpent[outcome] / totalShares[outcome]
totalShares[outcome] = totalShares[outcome] + shares
totalSpent[outcome] = totalSpent[outcome] + averagePrice * shares
const averagePrice = position === 0 ? 0 : spent / position
totalShares[outcome] = position + shares
totalSpent[outcome] = spent + averagePrice * shares
}
}
return sum(Object.values(totalSpent))
return sum([0, ...Object.values(totalSpent)])
}
function getDpmInvested(yourBets: Bet[]) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { filterDefined } from './util/array'
import { notification_preferences } from './user-notification-preferences'
import { ENV_CONFIG } from 'common/envs/constants'
export type User = {
id: string
@ -65,65 +66,15 @@ export type PrivateUser = {
initialDeviceToken?: string
initialIpAddress?: string
apiKey?: string
notificationPreferences: notification_subscription_types
notificationPreferences: notification_preferences
twitchInfo?: {
twitchName: string
controlToken: string
botEnabled?: boolean
needsRelinking?: boolean
}
}
export type notification_destination_types = 'email' | 'browser'
export type notification_subscription_types = {
// Watched Markets
all_comments_on_watched_markets: notification_destination_types[]
all_answers_on_watched_markets: notification_destination_types[]
// Comments
tipped_comments_on_watched_markets: notification_destination_types[]
comments_by_followed_users_on_watched_markets: notification_destination_types[]
all_replies_to_my_comments_on_watched_markets: notification_destination_types[]
all_replies_to_my_answers_on_watched_markets: notification_destination_types[]
all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
// Answers
answers_by_followed_users_on_watched_markets: notification_destination_types[]
answers_by_market_creator_on_watched_markets: notification_destination_types[]
all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[]
// On users' markets
your_contract_closed: notification_destination_types[]
all_comments_on_my_markets: notification_destination_types[]
all_answers_on_my_markets: notification_destination_types[]
subsidized_your_market: notification_destination_types[]
// Market updates
resolutions_on_watched_markets: notification_destination_types[]
resolutions_on_watched_markets_with_shares_in: notification_destination_types[]
market_updates_on_watched_markets: notification_destination_types[]
market_updates_on_watched_markets_with_shares_in: notification_destination_types[]
probability_updates_on_watched_markets: notification_destination_types[]
// Balance Changes
loan_income: notification_destination_types[]
betting_streaks: notification_destination_types[]
referral_bonuses: notification_destination_types[]
unique_bettors_on_your_contract: notification_destination_types[]
tips_on_your_comments: notification_destination_types[]
tips_on_your_markets: notification_destination_types[]
limit_order_fills: notification_destination_types[]
// General
tagged_user: notification_destination_types[]
on_new_follow: notification_destination_types[]
contract_from_followed_user: notification_destination_types[]
trending_markets: notification_destination_types[]
profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[]
thank_you_for_purchases: notification_destination_types[]
}
export type notification_subscribe_types = 'all' | 'less' | 'none'
export type PortfolioMetrics = {
investmentValue: number
balance: number
@ -135,121 +86,9 @@ export type PortfolioMetrics = {
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
export const getDefaultNotificationSettings = (
userId: string,
privateUser?: PrivateUser,
noEmails?: boolean
) => {
const {
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
}
export const BETTOR = ENV_CONFIG.bettor ?? 'bettor' // aka predictor
export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'bettors'
export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'bet' // aka predict
export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'bets'
export const PAST_BET = ENV_CONFIG.pastBet ?? 'bet' // aka prediction
export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'bets' // aka predictions

View File

@ -14,7 +14,8 @@ service cloud.firestore {
'manticmarkets@gmail.com',
'iansphilips@gmail.com',
'd4vidchee@gmail.com',
'federicoruizcassarino@gmail.com'
'federicoruizcassarino@gmail.com',
'ingawei@gmail.com'
]
}

View File

@ -1,7 +1,8 @@
import * as admin from 'firebase-admin'
import {
BetFillData,
BettingStreakData,
getDestinationsForUser,
ContractResolutionData,
Notification,
notification_reason_types,
} from '../../common/notification'
@ -9,7 +10,7 @@ import { User } from '../../common/user'
import { Contract } from '../../common/contract'
import { getPrivateUser, getValues } from './utils'
import { Comment } from '../../common/comment'
import { groupBy, uniq } from 'lodash'
import { groupBy, sum, uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
import { Answer } from '../../common/answer'
import { getContractBetMetrics } from '../../common/calculate'
@ -27,6 +28,8 @@ import {
sendNewUniqueBettorsEmail,
} from './emails'
import { filterDefined } from '../../common/util/array'
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
import { ContractFollow } from '../../common/follow'
const firestore = admin.firestore()
type recipients_to_reason_texts = {
@ -66,7 +69,7 @@ export const createNotification = async (
const { reason } = userToReasonTexts[userId]
const privateUser = await getPrivateUser(userId)
if (!privateUser) continue
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
reason
)
@ -158,7 +161,7 @@ export type replied_users_info = {
export const createCommentOrAnswerOrUpdatedContractNotification = async (
sourceId: string,
sourceType: 'comment' | 'answer' | 'contract',
sourceUpdateType: 'created' | 'updated' | 'resolved',
sourceUpdateType: 'created' | 'updated',
sourceUser: User,
idempotencyKey: string,
sourceText: string,
@ -166,17 +169,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
miscData?: {
repliedUsersInfo: replied_users_info
taggedUserIds: string[]
},
resolutionData?: {
bets: Bet[]
userInvestments: { [userId: string]: number }
userPayouts: { [userId: string]: number }
creator: User
creatorPayout: number
contract: Contract
outcome: string
resolutionProbability?: number
resolutions?: { [outcome: string]: number }
}
) => {
const { repliedUsersInfo, taggedUserIds } = miscData ?? {}
@ -229,14 +221,10 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
userId: string,
reason: notification_reason_types
) => {
if (
!stillFollowingContract(sourceContract.creatorId) ||
sourceUser.id == userId
)
return
if (!stillFollowingContract(userId) || sourceUser.id == userId) return
const privateUser = await getPrivateUser(userId)
if (!privateUser) return
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
reason
)
@ -275,24 +263,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
sourceUser.avatarUrl
)
emailRecipientIdsList.push(userId)
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'resolved' &&
resolutionData
) {
await sendMarketResolutionEmail(
reason,
privateUser,
resolutionData.userInvestments[userId] ?? 0,
resolutionData.userPayouts[userId] ?? 0,
sourceUser,
resolutionData.creatorPayout,
sourceContract,
resolutionData.outcome,
resolutionData.resolutionProbability,
resolutionData.resolutions
)
emailRecipientIdsList.push(userId)
}
}
@ -446,6 +416,9 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
)
}
//TODO: store all possible reasons why the user might be getting the notification
// and choose the most lenient that they have enabled so they will unsubscribe
// from the least important notifications
await notifyRepliedUser()
await notifyTaggedUsers()
await notifyContractCreator()
@ -468,7 +441,7 @@ export const createTipNotification = async (
) => {
const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser,
'tip_received'
)
@ -507,20 +480,22 @@ export const createBetFillNotification = async (
fromUser: User,
toUser: User,
bet: Bet,
userBet: LimitBet,
limitBet: LimitBet,
contract: Contract,
idempotencyKey: string
) => {
const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser,
'bet_fill'
)
if (!sendToBrowser) return
const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id)
const fill = limitBet.fills.find((fill) => fill.matchedBetId === bet.id)
const fillAmount = fill?.amount ?? 0
const remainingAmount =
limitBet.orderAmount - sum(limitBet.fills.map((f) => f.amount))
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
@ -531,7 +506,7 @@ export const createBetFillNotification = async (
reason: 'bet_fill',
createdTime: Date.now(),
isSeen: false,
sourceId: userBet.id,
sourceId: limitBet.id,
sourceType: 'bet',
sourceUpdateType: 'updated',
sourceUserName: fromUser.name,
@ -542,6 +517,14 @@ export const createBetFillNotification = async (
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceContractId: contract.id,
data: {
betOutcome: bet.outcome,
creatorOutcome: limitBet.outcome,
fillAmount,
probability: limitBet.limitProb,
limitOrderTotal: limitBet.orderAmount,
limitOrderRemaining: remainingAmount,
} as BetFillData,
}
return await notificationRef.set(removeUndefinedProps(notification))
@ -558,7 +541,7 @@ export const createReferralNotification = async (
) => {
const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser,
'you_referred_user'
)
@ -612,7 +595,7 @@ export const createLoanIncomeNotification = async (
) => {
const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser,
'loan_income'
)
@ -650,7 +633,7 @@ export const createChallengeAcceptedNotification = async (
) => {
const privateUser = await getPrivateUser(challengeCreator.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser,
'challenge_accepted'
)
@ -692,7 +675,7 @@ export const createBettingStreakBonusNotification = async (
) => {
const privateUser = await getPrivateUser(user.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser,
'betting_streak_incremented'
)
@ -739,7 +722,7 @@ export const createLikeNotification = async (
) => {
const privateUser = await getPrivateUser(toUser.id)
if (!privateUser) return
const { sendToBrowser } = await getDestinationsForUser(
const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser,
'liked_and_tipped_your_contract'
)
@ -786,7 +769,7 @@ export const createUniqueBettorBonusNotification = async (
) => {
const privateUser = await getPrivateUser(contractCreatorId)
if (!privateUser) return
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'unique_bettors_on_your_contract'
)
@ -876,7 +859,7 @@ export const createNewContractNotification = async (
) => {
const privateUser = await getPrivateUser(userId)
if (!privateUser) return
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
reason
)
@ -936,3 +919,130 @@ export const createNewContractNotification = async (
await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user')
}
}
export const createContractResolvedNotifications = async (
contract: Contract,
creator: User,
outcome: string,
probabilityInt: number | undefined,
resolutionValue: number | undefined,
resolutionData: {
bets: Bet[]
userInvestments: { [userId: string]: number }
userPayouts: { [userId: string]: number }
creator: User
creatorPayout: number
contract: Contract
outcome: string
resolutionProbability?: number
resolutions?: { [outcome: string]: number }
}
) => {
let resolutionText = outcome ?? contract.question
if (
contract.outcomeType === 'FREE_RESPONSE' ||
contract.outcomeType === 'MULTIPLE_CHOICE'
) {
const answerText = contract.answers.find(
(answer) => answer.id === outcome
)?.text
if (answerText) resolutionText = answerText
} else if (contract.outcomeType === 'BINARY') {
if (resolutionText === 'MKT' && probabilityInt)
resolutionText = `${probabilityInt}%`
else if (resolutionText === 'MKT') resolutionText = 'PROB'
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
if (resolutionText === 'MKT' && resolutionValue)
resolutionText = `${resolutionValue}`
}
const idempotencyKey = contract.id + '-resolved'
const createBrowserNotification = async (
userId: string,
reason: notification_reason_types
) => {
const notificationRef = firestore
.collection(`/users/${userId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId,
reason,
createdTime: Date.now(),
isSeen: false,
sourceId: contract.id,
sourceType: 'contract',
sourceUpdateType: 'resolved',
sourceContractId: contract.id,
sourceUserName: creator.name,
sourceUserUsername: creator.username,
sourceUserAvatarUrl: creator.avatarUrl,
sourceText: resolutionText,
sourceContractCreatorUsername: contract.creatorUsername,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceSlug: contract.slug,
sourceTitle: contract.question,
data: {
outcome,
userInvestment: resolutionData.userInvestments[userId] ?? 0,
userPayout: resolutionData.userPayouts[userId] ?? 0,
} as ContractResolutionData,
}
return await notificationRef.set(removeUndefinedProps(notification))
}
const sendNotificationsIfSettingsPermit = async (
userId: string,
reason: notification_reason_types
) => {
if (!stillFollowingContract(userId) || creator.id == userId) return
const privateUser = await getPrivateUser(userId)
if (!privateUser) return
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
reason
)
// Browser notifications
if (sendToBrowser) {
await createBrowserNotification(userId, reason)
}
// Emails notifications
if (sendToEmail)
await sendMarketResolutionEmail(
reason,
privateUser,
resolutionData.userInvestments[userId] ?? 0,
resolutionData.userPayouts[userId] ?? 0,
creator,
resolutionData.creatorPayout,
contract,
resolutionData.outcome,
resolutionData.resolutionProbability,
resolutionData.resolutions
)
}
const contractFollowersIds = (
await getValues<ContractFollow>(
firestore.collection(`contracts/${contract.id}/follows`)
)
).map((follow) => follow.id)
const stillFollowingContract = (userId: string) => {
return contractFollowersIds.includes(userId)
}
await Promise.all(
contractFollowersIds.map((id) =>
sendNotificationsIfSettingsPermit(
id,
resolutionData.userInvestments[id]
? 'resolution_on_contract_with_users_shares_in'
: 'resolution_on_contract_you_follow'
)
)
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,6 +27,7 @@ import { User } from '../../common/user'
import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes'
import { addHouseLiquidity } from './add-liquidity'
import { DAY_MS } from '../../common/util/time'
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
@ -109,6 +110,7 @@ const updateBettingStreak = async (
const bonusTxnDetails = {
currentBettingStreak: newBettingStreak,
}
// TODO: set the id of the txn to the eventId to prevent duplicates
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: fromUserId,
@ -119,11 +121,14 @@ const updateBettingStreak = async (
token: 'M$',
category: 'BETTING_STREAK_BONUS',
description: JSON.stringify(bonusTxnDetails),
}
data: bonusTxnDetails,
} as Omit<BettingStreakBonusTxn, 'id' | 'createdTime'>
return await runTxn(trans, bonusTxn)
})
if (!result.txn) {
log("betting streak bonus txn couldn't be made")
log('status:', result.status)
log('message:', result.message)
return
}
@ -186,7 +191,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
// Create combined txn for all new unique bettors
const bonusTxnDetails = {
contractId: contract.id,
uniqueBettorIds: newUniqueBettorIds,
uniqueNewBettorId: bettor.id,
}
const fromUserId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
@ -194,6 +199,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User
// TODO: set the id of the txn to the eventId to prevent duplicates
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: fromUser.id,
@ -204,12 +210,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
token: 'M$',
category: 'UNIQUE_BETTOR_BONUS',
description: JSON.stringify(bonusTxnDetails),
}
data: bonusTxnDetails,
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
return await runTxn(trans, bonusTxn)
})
if (result.status != 'success' || !result.txn) {
log(`No bonus for user: ${contract.creatorId} - reason:`, result.status)
log(`No bonus for user: ${contract.creatorId} - status:`, result.status)
log('message:', result.message)
} else {
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
await createUniqueBettorBonusNotification(

View File

@ -13,6 +13,10 @@ export const onUpdateContract = functions.firestore
if (!contractUpdater) throw new Error('Could not find contract updater')
const previousValue = change.before.data() as Contract
// Resolution is handled in resolve-market.ts
if (!previousValue.isResolved && contract.isResolved) return
if (
previousValue.closeTime !== contract.closeTime ||
previousValue.question !== contract.question

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ export type ColorType =
| 'gray'
| 'gradient'
| 'gray-white'
| 'highlight-blue'
export function Button(props: {
className?: string
@ -56,7 +57,9 @@ export function Button(props: {
color === 'gradient' &&
'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200',
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none',
color === 'highlight-blue' &&
'text-highlight-blue border-none shadow-none',
className
)}
disabled={disabled}

View File

@ -3,7 +3,7 @@ import algoliasearch from 'algoliasearch/lite'
import { SearchOptions } from '@algolia/client-search'
import { useRouter } from 'next/router'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import { PAST_BETS, User } from 'common/user'
import {
ContractHighlightOptions,
ContractsGrid,
@ -39,7 +39,7 @@ const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
export const SORTS = [
{ label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' },
{ label: 'Most traded', value: 'most-traded' },
{ label: `Most ${PAST_BETS}`, value: 'most-traded' },
{ label: '24h volume', value: '24-hour-vol' },
{ label: '24h change', value: 'prob-change-day' },
{ label: 'Last updated', value: 'last-updated' },
@ -77,9 +77,10 @@ export function ContractSearch(props: {
highlightOptions?: ContractHighlightOptions
onContractClick?: (contract: Contract) => void
hideOrderSelector?: boolean
cardHideOptions?: {
cardUIOptions?: {
hideGroupLink?: boolean
hideQuickBet?: boolean
noLinkAvatar?: boolean
}
headerClassName?: string
persistPrefix?: string
@ -101,7 +102,7 @@ export function ContractSearch(props: {
additionalFilter,
onContractClick,
hideOrderSelector,
cardHideOptions,
cardUIOptions,
highlightOptions,
headerClassName,
persistPrefix,
@ -164,6 +165,7 @@ export function ContractSearch(props: {
numericFilters,
page: requestedPage,
hitsPerPage: 20,
advancedSyntax: true,
})
// if there's a more recent request, forget about this one
if (id === requestId.current) {
@ -223,7 +225,7 @@ export function ContractSearch(props: {
showTime={state.showTime ?? undefined}
onContractClick={onContractClick}
highlightOptions={highlightOptions}
cardHideOptions={cardHideOptions}
cardUIOptions={cardUIOptions}
/>
)}
</Col>
@ -393,9 +395,7 @@ function ContractSearchControls(props: {
}
return (
<Col
className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)}
>
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
<Row className="gap-1 sm:gap-2">
<input
type="text"
@ -452,7 +452,7 @@ function ContractSearchControls(props: {
selected={pill === 'your-bets'}
onSelect={selectPill('your-bets')}
>
Your trades
Your {PAST_BETS}
</PillButton>
)}

View File

@ -81,18 +81,22 @@ export function SelectMarketsModal(props: {
</div>
)}
<div className="overflow-y-auto sm:px-8">
<div className="overflow-y-auto px-2 sm:px-8">
<ContractSearch
hideOrderSelector
onContractClick={addContract}
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
cardUIOptions={{
hideGroupLink: true,
hideQuickBet: true,
noLinkAvatar: true,
}}
highlightOptions={{
contractIds: contracts.map((c) => c.id),
highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}
additionalFilter={{}} /* hide pills */
headerClassName="bg-white"
headerClassName="bg-white sticky"
{...contractSearchOptions}
/>
</div>

View File

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

View File

@ -1,9 +1,4 @@
import {
ClockIcon,
DatabaseIcon,
PencilIcon,
UserGroupIcon,
} from '@heroicons/react/outline'
import { ClockIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { Editor } from '@tiptap/react'
import dayjs from 'dayjs'
@ -16,9 +11,8 @@ import { DateTimeTooltip } from '../datetime-tooltip'
import { fromNow } from 'web/lib/util/time'
import { Avatar } from '../avatar'
import { useState } from 'react'
import { ContractInfoDialog } from './contract-info-dialog'
import NewContractBadge from '../new-contract-badge'
import { UserFollowButton } from '../follow-button'
import { MiniUserFollowButton } from '../follow-button'
import { DAY_MS } from 'common/util/time'
import { useUser } from 'web/hooks/use-user'
import { exhibitExts } from 'common/util/parse'
@ -33,7 +27,11 @@ import { contractMetrics } from 'common/contract-details'
import { UserLink } from 'web/components/user-link'
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
import { Tooltip } from 'web/components/tooltip'
import { useWindowSize } from 'web/hooks/use-window-size'
import { ExtraContractActionsRow } from './extra-contract-actions-row'
import { PlusCircleIcon } from '@heroicons/react/solid'
import { GroupLink } from 'common/group'
import { Subtitle } from '../subtitle'
import { useIsMobile } from 'web/hooks/use-is-mobile'
export type ShowTime = 'resolve-date' | 'close-date'
@ -86,8 +84,9 @@ export function AvatarDetails(props: {
contract: Contract
className?: string
short?: boolean
noLink?: boolean
}) {
const { contract, short, className } = props
const { contract, short, className, noLink } = props
const { creatorName, creatorUsername, creatorAvatarUrl } = contract
return (
@ -98,8 +97,14 @@ export function AvatarDetails(props: {
username={creatorUsername}
avatarUrl={creatorAvatarUrl}
size={6}
noLink={noLink}
/>
<UserLink
name={creatorName}
username={creatorUsername}
short={short}
noLink={noLink}
/>
<UserLink name={creatorName} username={creatorUsername} short={short} />
</Row>
)
}
@ -109,85 +114,146 @@ export function ContractDetails(props: {
disabled?: boolean
}) {
const { contract, disabled } = props
const {
closeTime,
creatorName,
creatorUsername,
creatorId,
creatorAvatarUrl,
resolutionTime,
} = contract
const { volumeLabel, resolvedDate } = contractMetrics(contract)
const user = useUser()
const isCreator = user?.id === creatorId
const [open, setOpen] = useState(false)
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 600
const groupToDisplay = getGroupLinkToDisplay(contract)
const groupInfo = groupToDisplay ? (
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
<a
className={clsx(
linkClass,
'flex flex-row items-center truncate pr-0 sm:pr-2',
isMobile ? 'max-w-[140px]' : 'max-w-[250px]'
)}
>
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
<span className="items-center truncate">{groupToDisplay.name}</span>
</a>
</Link>
) : (
<Button
size={'xs'}
className={'max-w-[200px] pr-2'}
color={'gray-white'}
onClick={() => !groupToDisplay && setOpen(true)}
>
<Row>
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
<span className="truncate">No Group</span>
</Row>
</Button>
)
const isMobile = useIsMobile()
return (
<Row className="flex-1 flex-wrap items-center gap-2 text-sm text-gray-500 md:gap-x-4 md:gap-y-2">
<Row className="items-center gap-2">
<Avatar
username={creatorUsername}
avatarUrl={creatorAvatarUrl}
noLink={disabled}
size={6}
/>
{disabled ? (
creatorName
) : (
<UserLink
className="whitespace-nowrap"
name={creatorName}
username={creatorUsername}
short={isMobile}
/>
)}
{!disabled && <UserFollowButton userId={creatorId} small />}
<Col>
<Row className="justify-between">
<MarketSubheader contract={contract} disabled={disabled} />
<div className="mt-0">
<ExtraContractActionsRow contract={contract} />
</div>
</Row>
<Row>
{disabled ? (
groupInfo
) : !groupToDisplay && !user ? (
<div />
) : (
{/* GROUPS */}
{isMobile && (
<div className="mt-2">
<MarketGroups
contract={contract}
isMobile={isMobile}
disabled={disabled}
/>
</div>
)}
</Col>
)
}
export function MarketSubheader(props: {
contract: Contract
disabled?: boolean
}) {
const { contract, disabled } = props
const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract
const { resolvedDate } = contractMetrics(contract)
const user = useUser()
const isCreator = user?.id === creatorId
const isMobile = useIsMobile()
return (
<Row>
<Avatar
username={creatorUsername}
avatarUrl={creatorAvatarUrl}
noLink={disabled}
size={9}
className="mr-1.5"
/>
{!disabled && (
<div className="absolute mt-3 ml-[11px]">
<MiniUserFollowButton userId={creatorId} />
</div>
)}
<Col className="text-greyscale-6 ml-2 flex-1 flex-wrap text-sm">
<Row className="w-full justify-between ">
{disabled ? (
creatorName
) : (
<UserLink
className="my-auto whitespace-nowrap"
name={creatorName}
username={creatorUsername}
short={isMobile}
/>
)}
</Row>
<Row className="text-2xs text-greyscale-4 gap-2 sm:text-xs">
<CloseOrResolveTime
contract={contract}
resolvedDate={resolvedDate}
isCreator={isCreator}
/>
{!isMobile && (
<MarketGroups
contract={contract}
isMobile={isMobile}
disabled={disabled}
/>
)}
</Row>
</Col>
</Row>
)
}
export function CloseOrResolveTime(props: {
contract: Contract
resolvedDate: any
isCreator: boolean
}) {
const { contract, resolvedDate, isCreator } = props
const { resolutionTime, closeTime } = contract
if (!!closeTime || !!resolvedDate) {
return (
<Row className="select-none items-center gap-1">
{resolvedDate && resolutionTime ? (
<>
<DateTimeTooltip text="Market resolved:" time={resolutionTime}>
<Row>
<div>resolved&nbsp;</div>
{resolvedDate}
</Row>
</DateTimeTooltip>
</>
) : null}
{!resolvedDate && closeTime && (
<Row>
{groupInfo}
{user && groupToDisplay && (
<Button
size={'xs'}
color={'gray-white'}
{dayjs().isBefore(closeTime) && <div>closes&nbsp;</div>}
{!dayjs().isBefore(closeTime) && <div>closed&nbsp;</div>}
<EditableCloseDate
closeTime={closeTime}
contract={contract}
isCreator={isCreator ?? false}
/>
</Row>
)}
</Row>
)
} else return <></>
}
export function MarketGroups(props: {
contract: Contract
isMobile: boolean | undefined
disabled: boolean | undefined
}) {
const [open, setOpen] = useState(false)
const user = useUser()
const { contract, isMobile, disabled } = props
const groupToDisplay = getGroupLinkToDisplay(contract)
return (
<>
<Row className="align-middle">
<GroupDisplay groupToDisplay={groupToDisplay} isMobile={isMobile} />
{!disabled && (
<Row>
{user && (
<button
className="text-greyscale-4 hover:text-greyscale-3"
onClick={() => setOpen(!open)}
>
<PencilIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" />
</Button>
<PlusCircleIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" />
</button>
)}
</Row>
)}
@ -201,45 +267,7 @@ export function ContractDetails(props: {
<ContractGroupsList contract={contract} user={user} />
</Col>
</Modal>
{(!!closeTime || !!resolvedDate) && (
<Row className="hidden items-center gap-1 md:inline-flex">
{resolvedDate && resolutionTime ? (
<>
<ClockIcon className="h-5 w-5" />
<DateTimeTooltip text="Market resolved:" time={resolutionTime}>
{resolvedDate}
</DateTimeTooltip>
</>
) : null}
{!resolvedDate && closeTime && user && (
<>
<ClockIcon className="h-5 w-5" />
<EditableCloseDate
closeTime={closeTime}
contract={contract}
isCreator={isCreator ?? false}
/>
</>
)}
</Row>
)}
{user && (
<>
<Row className="hidden items-center gap-1 md:inline-flex">
<DatabaseIcon className="h-5 w-5" />
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
{!disabled && (
<ContractInfoDialog
contract={contract}
className={'hidden md:inline-flex'}
/>
)}
</>
)}
</Row>
</>
)
}
@ -280,12 +308,12 @@ export function ExtraMobileContractDetails(props: {
!resolvedDate &&
closeTime && (
<Col className={'items-center text-sm text-gray-500'}>
<Row className={'text-gray-400'}>Closes&nbsp;</Row>
<EditableCloseDate
closeTime={closeTime}
contract={contract}
isCreator={creatorId === user?.id}
/>
<Row className={'text-gray-400'}>Ends</Row>
</Col>
)
)}
@ -305,6 +333,45 @@ export function ExtraMobileContractDetails(props: {
)
}
export function GroupDisplay(props: {
groupToDisplay?: GroupLink | null
isMobile?: boolean
}) {
const { groupToDisplay, isMobile } = props
if (groupToDisplay) {
return (
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
<a
className={clsx(
'flex flex-row items-center truncate pr-1',
isMobile ? 'max-w-[140px]' : 'max-w-[250px]'
)}
>
<div className="bg-greyscale-4 hover:bg-greyscale-3 text-2xs items-center truncate rounded-full px-2 text-white sm:text-xs">
{groupToDisplay.name}
</div>
</a>
</Link>
)
} else
return (
<Row
className={clsx(
'cursor-default select-none items-center truncate pr-1',
isMobile ? 'max-w-[140px]' : 'max-w-[250px]'
)}
>
<div
className={clsx(
'bg-greyscale-4 text-2xs items-center truncate rounded-full px-2 text-white sm:text-xs'
)}
>
No Group
</div>
</Row>
)
}
function EditableCloseDate(props: {
closeTime: number
contract: Contract
@ -356,47 +423,59 @@ function EditableCloseDate(props: {
return (
<>
{isEditingCloseTime ? (
<Row className="z-10 mr-2 w-full shrink-0 items-center gap-1">
<input
type="date"
className="input input-bordered shrink-0"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value)}
min={Date.now()}
value={closeDate}
/>
<input
type="time"
className="input input-bordered shrink-0"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseHoursMinutes(e.target.value)}
min="00:00"
value={closeHoursMinutes}
/>
<Button size={'xs'} color={'blue'} onClick={onSave}>
<Modal
size="sm"
open={isEditingCloseTime}
setOpen={setIsEditingCloseTime}
position="top"
>
<Col className="rounded bg-white px-8 pb-8">
<Subtitle text="Edit Close Date" />
<Row className="z-10 mr-2 w-full shrink-0 flex-wrap items-center gap-2">
<input
type="date"
className="input input-bordered w-full shrink-0 sm:w-fit"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value)}
min={Date.now()}
value={closeDate}
/>
<input
type="time"
className="input input-bordered w-full shrink-0 sm:w-max"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseHoursMinutes(e.target.value)}
min="00:00"
value={closeHoursMinutes}
/>
</Row>
<Button
className="mt-2"
size={'xs'}
color={'indigo'}
onClick={onSave}
>
Done
</Button>
</Row>
) : (
<DateTimeTooltip
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
time={closeTime}
</Col>
</Modal>
<DateTimeTooltip
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
time={closeTime}
>
<span
className={isCreator ? 'cursor-pointer' : ''}
onClick={() => isCreator && setIsEditingCloseTime(true)}
>
<span
className={isCreator ? 'cursor-pointer' : ''}
onClick={() => isCreator && setIsEditingCloseTime(true)}
>
{isSameDay ? (
<span className={'capitalize'}> {fromNow(closeTime)}</span>
) : isSameYear ? (
dayJsCloseTime.format('MMM D')
) : (
dayJsCloseTime.format('MMM D, YYYY')
)}
</span>
</DateTimeTooltip>
)}
{isSameDay ? (
<span className={'capitalize'}> {fromNow(closeTime)}</span>
) : isSameYear ? (
dayJsCloseTime.format('MMM D')
) : (
dayJsCloseTime.format('MMM D, YYYY')
)}
</span>
</DateTimeTooltip>
</>
)
}

View File

@ -2,6 +2,7 @@ import { DotsHorizontalIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import dayjs from 'dayjs'
import { useState } from 'react'
import { capitalize } from 'lodash'
import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
@ -18,6 +19,8 @@ import { deleteField } from 'firebase/firestore'
import ShortToggle from '../widgets/short-toggle'
import { DuplicateContractButton } from '../copy-contract-button'
import { Row } from '../layout/row'
import { BETTORS } from 'common/user'
import { Button } from '../button'
export const contractDetailsButtonClassName =
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
@ -37,10 +40,16 @@ export function ContractInfoDialog(props: {
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a')
const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } =
contract
const {
createdTime,
closeTime,
resolutionTime,
uniqueBettorCount,
mechanism,
outcomeType,
id,
} = contract
const bettorsCount = contract.uniqueBettorCount ?? 'Unknown'
const typeDisplay =
outcomeType === 'BINARY'
? 'YES / NO'
@ -67,19 +76,21 @@ export function ContractInfoDialog(props: {
return (
<>
<button
<Button
size="sm"
color="gray-white"
className={clsx(contractDetailsButtonClassName, className)}
onClick={() => setOpen(true)}
>
<DotsHorizontalIcon
className={clsx('h-6 w-6 flex-shrink-0')}
className={clsx('h-5 w-5 flex-shrink-0')}
aria-hidden="true"
/>
</button>
</Button>
<Modal open={open} setOpen={setOpen}>
<Col className="gap-4 rounded bg-white p-6">
<Title className="!mt-0 !mb-0" text="Market info" />
<Title className="!mt-0 !mb-0" text="This Market" />
<table className="table-compact table-zebra table w-full text-gray-500">
<tbody>
@ -129,14 +140,9 @@ export function ContractInfoDialog(props: {
<td>{formatMoney(contract.volume)}</td>
</tr>
{/* <tr>
<td>Creator earnings</td>
<td>{formatMoney(contract.collectedFees.creatorFee)}</td>
</tr> */}
<tr>
<td>Traders</td>
<td>{bettorsCount}</td>
<td>{capitalize(BETTORS)}</td>
<td>{uniqueBettorCount ?? '0'}</td>
</tr>
<tr>

View File

@ -12,6 +12,7 @@ import { FeedComment } from '../feed/feed-comments'
import { Spacer } from '../layout/spacer'
import { Leaderboard } from '../leaderboard'
import { Title } from '../title'
import { BETTORS } from 'common/user'
export function ContractLeaderboard(props: {
contract: Contract
@ -48,7 +49,7 @@ export function ContractLeaderboard(props: {
return users && users.length > 0 ? (
<Leaderboard
title="🏅 Top traders"
title={`🏅 Top ${BETTORS}`}
users={users || []}
columns={[
{

View File

@ -25,11 +25,11 @@ import {
NumericContract,
PseudoNumericContract,
} from 'common/contract'
import { ContractDetails, ExtraMobileContractDetails } from './contract-details'
import { ContractDetails } from './contract-details'
import { NumericGraph } from './numeric-graph'
const OverviewQuestion = (props: { text: string }) => (
<Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} />
<Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} />
)
const BetWidget = (props: { contract: CPMMContract }) => {
@ -73,7 +73,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
const { contract, bets } = props
return (
<Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4">
<Col className="gap-1 px-2">
<ContractDetails contract={contract} />
<Row className="justify-between gap-4">
<OverviewQuestion text={contract.question} />
@ -85,7 +85,6 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
</Row>
<Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} />
<ExtraMobileContractDetails contract={contract} />
{tradingAllowed(contract) && (
<BetWidget contract={contract as CPMMBinaryContract} />
)}
@ -113,10 +112,6 @@ const ChoiceOverview = (props: {
</Col>
<Col className={'mb-1 gap-y-2'}>
<AnswersGraph contract={contract} bets={[...bets].reverse()} />
<ExtraMobileContractDetails
contract={contract}
forceShowVolume={true}
/>
</Col>
</Col>
)
@ -140,7 +135,6 @@ const PseudoNumericOverview = (props: {
</Row>
<Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} />
<ExtraMobileContractDetails contract={contract} />
{tradingAllowed(contract) && <BetWidget contract={contract} />}
</Row>
</Col>

View File

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

View File

@ -21,9 +21,10 @@ export function ContractsGrid(props: {
loadMore?: () => void
showTime?: ShowTime
onContractClick?: (contract: Contract) => void
cardHideOptions?: {
cardUIOptions?: {
hideQuickBet?: boolean
hideGroupLink?: boolean
noLinkAvatar?: boolean
}
highlightOptions?: ContractHighlightOptions
trackingPostfix?: string
@ -34,11 +35,11 @@ export function ContractsGrid(props: {
showTime,
loadMore,
onContractClick,
cardHideOptions,
cardUIOptions,
highlightOptions,
trackingPostfix,
} = props
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
const { hideQuickBet, hideGroupLink, noLinkAvatar } = cardUIOptions || {}
const { contractIds, highlightClassName } = highlightOptions || {}
const onVisibilityUpdated = useCallback(
(visible) => {
@ -80,6 +81,7 @@ export function ContractsGrid(props: {
onClick={
onContractClick ? () => onContractClick(contract) : undefined
}
noLinkAvatar={noLinkAvatar}
hideQuickBet={hideQuickBet}
hideGroupLink={hideGroupLink}
trackingPostfix={trackingPostfix}
@ -108,6 +110,7 @@ export function CreatorContractsList(props: {
return (
<ContractSearch
headerClassName="sticky"
user={user}
defaultSort="newest"
defaultFilter="all"

View File

@ -11,38 +11,29 @@ import { FollowMarketButton } from 'web/components/follow-market-button'
import { LikeMarketButton } from 'web/components/contract/like-market-button'
import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog'
import { Col } from 'web/components/layout/col'
import { withTracking } from 'web/lib/service/analytics'
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
import { CHALLENGES_ENABLED } from 'common/challenge'
import ChallengeIcon from 'web/lib/icons/challenge-icon'
export function ExtraContractActionsRow(props: { contract: Contract }) {
const { contract } = props
const { outcomeType, resolution } = contract
const user = useUser()
const [isShareOpen, setShareOpen] = useState(false)
const [openCreateChallengeModal, setOpenCreateChallengeModal] =
useState(false)
const showChallenge =
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
return (
<Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}>
<Row>
<FollowMarketButton contract={contract} user={user} />
{user?.id !== contract.creatorId && (
<LikeMarketButton contract={contract} user={user} />
)}
<Button
size="lg"
size="sm"
color="gray-white"
className={'flex'}
onClick={() => {
setShareOpen(true)
}}
>
<Col className={'items-center sm:flex-row'}>
<ShareIcon
className={clsx('h-[24px] w-5 sm:mr-2')}
aria-hidden="true"
/>
<span>Share</span>
</Col>
<Row>
<ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" />
</Row>
<ShareModal
isOpen={isShareOpen}
setOpen={setShareOpen}
@ -50,35 +41,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
user={user}
/>
</Button>
{showChallenge && (
<Button
size="lg"
color="gray-white"
className="max-w-xs self-center"
onClick={withTracking(
() => setOpenCreateChallengeModal(true),
'click challenge button'
)}
>
<Col className="items-center sm:flex-row">
<ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" />
<span>Challenge</span>
</Col>
<CreateChallengeModal
isOpen={openCreateChallengeModal}
setOpen={setOpenCreateChallengeModal}
user={user}
contract={contract}
/>
</Button>
)}
<FollowMarketButton contract={contract} user={user} />
{user?.id !== contract.creatorId && (
<LikeMarketButton contract={contract} user={user} />
)}
<Col className={'justify-center md:hidden'}>
<Col className={'justify-center'}>
<ContractInfoDialog contract={contract} />
</Col>
</Row>

View File

@ -38,15 +38,16 @@ export function LikeMarketButton(props: {
return (
<Button
size={'lg'}
size={'sm'}
className={'max-w-xs self-center'}
color={'gray-white'}
onClick={onLike}
>
<Col className={'items-center sm:flex-row'}>
<Col className={'relative items-center sm:flex-row'}>
<HeartIcon
className={clsx(
'h-[24px] w-5 sm:mr-2',
'h-5 w-5 sm:h-6 sm:w-6',
totalTipped > 0 ? 'mr-2' : '',
user &&
(userLikedContractIds?.includes(contract.id) ||
(!likes && contract.likedByUserIds?.includes(user.id)))
@ -54,7 +55,18 @@ export function LikeMarketButton(props: {
: ''
)}
/>
Tip {totalTipped > 0 ? `(${formatMoney(totalTipped)})` : ''}
{totalTipped > 0 && (
<div
className={clsx(
'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
totalTipped > 99
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
)}
>
{totalTipped}
</div>
)}
</Col>
</Button>
)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,27 @@
import type { MentionOptions } from '@tiptap/extension-mention'
import { searchInAny } from 'common/util/parse'
import { orderBy } from 'lodash'
import { getCachedContracts } from 'web/hooks/use-contracts'
import { MentionList } from './contract-mention-list'
import { PluginKey } from 'prosemirror-state'
import { makeMentionRender } from './mention-suggestion'
type Suggestion = MentionOptions['suggestion']
const beginsWith = (text: string, query: string) =>
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
export const contractMentionSuggestion: Suggestion = {
char: '%',
allowSpaces: true,
pluginKey: new PluginKey('contract-mention'),
items: async ({ query }) =>
orderBy(
(await getCachedContracts()).filter((c) =>
searchInAny(query, c.question)
),
[(c) => [c.question].some((s) => beginsWith(s, query))],
['desc', 'desc']
).slice(0, 5),
render: makeMentionRender(MentionList),
}

View File

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

View File

@ -5,6 +5,7 @@ import { orderBy } from 'lodash'
import tippy from 'tippy.js'
import { getCachedUsers } from 'web/hooks/use-users'
import { MentionList } from './mention-list'
type Render = Suggestion['render']
type Suggestion = MentionOptions['suggestion']
@ -24,12 +25,16 @@ export const mentionSuggestion: Suggestion = {
],
['desc', 'desc']
).slice(0, 5),
render: () => {
render: makeMentionRender(MentionList),
}
export function makeMentionRender(mentionList: any): Render {
return () => {
let component: ReactRenderer
let popup: ReturnType<typeof tippy>
return {
onStart: (props) => {
component = new ReactRenderer(MentionList, {
component = new ReactRenderer(mentionList, {
props,
editor: props.editor,
})
@ -59,10 +64,16 @@ export const mentionSuggestion: Suggestion = {
})
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup?.[0].hide()
return true
}
if (props.event.key)
if (
props.event.key === 'Escape' ||
// Also break out of the mention if the tooltip isn't visible
(props.event.key === 'Enter' && !popup?.[0].state.isShown)
) {
popup?.[0].destroy()
component?.destroy()
return false
}
return (component?.ref as any)?.onKeyDown(props)
},
onExit() {
@ -70,5 +81,5 @@ export const mentionSuggestion: Suggestion = {
component?.destroy()
},
}
},
}
}

View File

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

View File

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

View File

@ -14,6 +14,7 @@ import { SiteLink } from 'web/components/site-link'
import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
import { Challenge } from 'common/challenge'
import { UserLink } from 'web/components/user-link'
import { BETTOR } from 'common/user'
export function FeedBet(props: { contract: Contract; bet: Bet }) {
const { contract, bet } = props
@ -94,7 +95,7 @@ export function BetStatusText(props: {
{!hideUser ? (
<UserLink name={bet.userName} username={bet.userUsername} />
) : (
<span>{self?.id === bet.userId ? 'You' : 'A trader'}</span>
<span>{self?.id === bet.userId ? 'You' : `A ${BETTOR}`}</span>
)}{' '}
{bought} {money}
{outOfTotalAmount}

View File

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

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'
import dayjs from 'dayjs'
import { User } from 'common/user'
import { BETTOR, User } from 'common/user'
import { useUser, useUserById } from 'web/hooks/use-user'
import { Row } from 'web/components/layout/row'
import { Avatar, EmptyAvatar } from 'web/components/avatar'
@ -9,17 +9,13 @@ 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, isAnte } = liquidity
const { userId, createdTime } = liquidity
const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01')
// eslint-disable-next-line react-hooks/rules-of-hooks
@ -28,13 +24,6 @@ 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="items-center gap-2 pt-3">
{isSelf ? (
@ -74,7 +63,7 @@ export function LiquidityStatusText(props: {
{bettor ? (
<UserLink name={bettor.name} username={bettor.username} />
) : (
<span>{isSelf ? 'You' : 'A trader'}</span>
<span>{isSelf ? 'You' : `A ${BETTOR}`}</span>
)}{' '}
{bought} a subsidy of {money}
<RelativeTimestamp time={createdTime} />

View File

@ -1,4 +1,6 @@
import { CheckCircleIcon, PlusCircleIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { useEffect, useRef, useState } from 'react'
import { useFollows } from 'web/hooks/use-follows'
import { useUser } from 'web/hooks/use-user'
import { follow, unfollow } from 'web/lib/firebase/users'
@ -54,18 +56,73 @@ export function FollowButton(props: {
export function UserFollowButton(props: { userId: string; small?: boolean }) {
const { userId, small } = props
const currentUser = useUser()
const following = useFollows(currentUser?.id)
const user = useUser()
const following = useFollows(user?.id)
const isFollowing = following?.includes(userId)
if (!currentUser || currentUser.id === userId) return null
if (!user || user.id === userId) return null
return (
<FollowButton
isFollowing={isFollowing}
onFollow={() => follow(currentUser.id, userId)}
onUnfollow={() => unfollow(currentUser.id, userId)}
onFollow={() => follow(user.id, userId)}
onUnfollow={() => unfollow(user.id, userId)}
small={small}
/>
)
}
export function MiniUserFollowButton(props: { userId: string }) {
const { userId } = props
const user = useUser()
const following = useFollows(user?.id)
const isFollowing = following?.includes(userId)
const isFirstRender = useRef(true)
const [justFollowed, setJustFollowed] = useState(false)
useEffect(() => {
if (isFirstRender.current) {
if (isFollowing != undefined) {
isFirstRender.current = false
}
return
}
if (isFollowing) {
setJustFollowed(true)
setTimeout(() => {
setJustFollowed(false)
}, 1000)
}
}, [isFollowing])
if (justFollowed) {
return (
<CheckCircleIcon
className={clsx(
'text-highlight-blue ml-3 mt-2 h-5 w-5 rounded-full bg-white sm:mr-2'
)}
aria-hidden="true"
/>
)
}
if (
!user ||
user.id === userId ||
isFollowing ||
!user ||
isFollowing === undefined
)
return null
return (
<>
<button onClick={withTracking(() => follow(user.id, userId), 'follow')}>
<PlusCircleIcon
className={clsx(
'text-highlight-blue hover:text-hover-blue mt-2 ml-3 h-5 w-5 rounded-full bg-white sm:mr-2'
)}
aria-hidden="true"
/>
</button>
</>
)
}

View File

@ -25,7 +25,7 @@ export const FollowMarketButton = (props: {
return (
<Button
size={'lg'}
size={'sm'}
color={'gray-white'}
onClick={async () => {
if (!user) return firebaseLogin()
@ -56,13 +56,19 @@ export const FollowMarketButton = (props: {
>
{followers?.includes(user?.id ?? 'nope') ? (
<Col className={'items-center gap-x-2 sm:flex-row'}>
<EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" />
Unwatch
<EyeOffIcon
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
aria-hidden="true"
/>
{/* Unwatch */}
</Col>
) : (
<Col className={'items-center gap-x-2 sm:flex-row'}>
<EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" />
Watch
<EyeIcon
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
aria-hidden="true"
/>
{/* Watch */}
</Col>
)}
<WatchMarketModal

View File

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

View File

@ -0,0 +1,94 @@
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
import { Item } from './sidebar'
import clsx from 'clsx'
import { trackCallback } from 'web/lib/service/analytics'
import TrophyIcon from 'web/lib/icons/trophy-icon'
import { useUser } from 'web/hooks/use-user'
import NotificationsIcon from '../notifications-icon'
import router from 'next/router'
import { userProfileItem } from './nav-bar'
const mobileGroupNavigation = [
{ name: 'About', key: 'about', icon: ClipboardIcon },
{ name: 'Markets', key: 'markets', icon: HomeIcon },
{ name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon },
]
const mobileGeneralNavigation = [
{
name: 'Notifications',
key: 'notifications',
icon: NotificationsIcon,
href: '/notifications',
},
]
export function GroupNavBar(props: {
currentPage: string
onClick: (key: string) => void
}) {
const { currentPage } = props
const user = useUser()
return (
<nav className="fixed inset-x-0 bottom-0 z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
{mobileGroupNavigation.map((item) => (
<NavBarItem
key={item.name}
item={item}
currentPage={currentPage}
onClick={props.onClick}
/>
))}
{mobileGeneralNavigation.map((item) => (
<NavBarItem
key={item.name}
item={item}
currentPage={currentPage}
onClick={() => {
router.push(item.href)
}}
/>
))}
{user && (
<NavBarItem
key={'profile'}
currentPage={currentPage}
onClick={() => {
router.push(`/${user.username}?tab=trades`)
}}
item={userProfileItem(user)}
/>
)}
</nav>
)
}
function NavBarItem(props: {
item: Item
currentPage: string
onClick: (key: string) => void
}) {
const { item, currentPage } = props
const track = trackCallback(
`group navbar: ${item.trackingEventName ?? item.name}`
)
return (
<button onClick={() => props.onClick(item.key ?? '#')}>
<a
className={clsx(
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',
currentPage === item.key && 'bg-gray-200 text-indigo-700'
)}
onClick={track}
>
{item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />}
{item.name}
</a>
</button>
)
}

View File

@ -0,0 +1,90 @@
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { useUser } from 'web/hooks/use-user'
import { ManifoldLogo } from './manifold-logo'
import { ProfileSummary } from './profile-menu'
import React from 'react'
import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button'
import CornerDownRightIcon from 'web/lib/icons/corner-down-right-icon'
import NotificationsIcon from '../notifications-icon'
import { SidebarItem } from './sidebar'
import { buildArray } from 'common/util/array'
import { User } from 'common/user'
import { Row } from '../layout/row'
import { Col } from '../layout/col'
const groupNavigation = [
{ name: 'Markets', key: 'markets', icon: HomeIcon },
{ name: 'About', key: 'about', icon: ClipboardIcon },
{ name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon },
]
const generalNavigation = (user?: User | null) =>
buildArray(
user && {
name: 'Notifications',
href: `/notifications`,
key: 'notifications',
icon: NotificationsIcon,
}
)
export function GroupSidebar(props: {
groupName: string
className?: string
onClick: (key: string) => void
joinOrAddQuestionsButton: React.ReactNode
currentKey: string
}) {
const { className, groupName, currentKey } = props
const user = useUser()
return (
<nav
aria-label="Group Sidebar"
className={clsx('flex max-h-[100vh] flex-col', className)}
>
<ManifoldLogo className="pt-6" twoLine />
<Row className="pl-2">
<Col className="flex justify-center">
<CornerDownRightIcon className=" h-6 w-6 text-indigo-700" />
</Col>
<Col>
<div className={' text-2xl text-indigo-700 sm:mb-1 sm:mt-3'}>
{groupName}
</div>
</Col>
</Row>
<div className=" min-h-0 shrink flex-col items-stretch gap-1 pt-6 lg:flex ">
{user ? (
<ProfileSummary user={user} />
) : (
<SignInButton className="mb-4" />
)}
</div>
{/* Desktop navigation */}
{groupNavigation.map((item) => (
<SidebarItem
key={item.key}
item={item}
currentPage={currentKey}
onClick={props.onClick}
/>
))}
{generalNavigation(user).map((item) => (
<SidebarItem
key={item.key}
item={item}
currentPage={currentKey}
onClick={props.onClick}
/>
))}
{props.joinOrAddQuestionsButton}
</nav>
)
}

View File

@ -17,6 +17,9 @@ import { useRouter } from 'next/router'
import NotificationsIcon from 'web/components/notifications-icon'
import { useIsIframe } from 'web/hooks/use-is-iframe'
import { trackCallback } from 'web/lib/service/analytics'
import { User } from 'common/user'
import { PAST_BETS } from 'common/user'
function getNavigation() {
return [
@ -34,6 +37,21 @@ const signedOutNavigation = [
{ name: 'Explore', href: '/home', icon: SearchIcon },
]
export const userProfileItem = (user: User) => ({
name: formatMoney(user.balance),
trackingEventName: 'profile',
href: `/${user.username}?tab=${PAST_BETS}`,
icon: () => (
<Avatar
className="mx-auto my-1"
size="xs"
username={user.username}
avatarUrl={user.avatarUrl}
noLink
/>
),
})
// From https://codepen.io/chris__sev/pen/QWGvYbL
export function BottomNavBar() {
const [sidebarOpen, setSidebarOpen] = useState(false)
@ -61,20 +79,7 @@ export function BottomNavBar() {
<NavBarItem
key={'profile'}
currentPage={currentPage}
item={{
name: formatMoney(user.balance),
trackingEventName: 'profile',
href: `/${user.username}?tab=trades`,
icon: () => (
<Avatar
className="mx-auto my-1"
size="xs"
username={user.username}
avatarUrl={user.avatarUrl}
noLink
/>
),
}}
item={userProfileItem(user)}
/>
)}
<div
@ -98,7 +103,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) {
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
return (
<Link href={item.href}>
<Link href={item.href ?? '#'}>
<a
className={clsx(
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',

View File

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

View File

@ -13,7 +13,7 @@ import Router, { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user'
import { firebaseLogout, User } from 'web/lib/firebase/users'
import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu'
import { MenuButton, MenuItem } from './menu'
import { ProfileSummary } from './profile-menu'
import NotificationsIcon from 'web/components/notifications-icon'
import React from 'react'
@ -139,7 +139,7 @@ function getMoreMobileNav() {
}
if (IS_PRIVATE_MANIFOLD) return [signOut]
return buildArray<Item>(
return buildArray<MenuItem>(
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[
{ name: 'Groups', href: '/groups' },
@ -156,39 +156,59 @@ function getMoreMobileNav() {
export type Item = {
name: string
trackingEventName?: string
href: string
href?: string
key?: string
icon?: React.ComponentType<{ className?: string }>
}
function SidebarItem(props: { item: Item; currentPage: string }) {
const { item, currentPage } = props
return (
<Link href={item.href} key={item.name}>
<a
onClick={trackCallback('sidebar: ' + item.name)}
className={clsx(
item.href == currentPage
? 'bg-gray-200 text-gray-900'
: 'text-gray-600 hover:bg-gray-100',
'group flex items-center rounded-md px-3 py-2 text-sm font-medium'
)}
aria-current={item.href == currentPage ? 'page' : undefined}
>
{item.icon && (
<item.icon
className={clsx(
item.href == currentPage
? 'text-gray-500'
: 'text-gray-400 group-hover:text-gray-500',
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
)}
aria-hidden="true"
/>
)}
<span className="truncate">{item.name}</span>
</a>
</Link>
export function SidebarItem(props: {
item: Item
currentPage: string
onClick?: (key: string) => void
}) {
const { item, currentPage, onClick } = props
const isCurrentPage =
item.href != null ? item.href === currentPage : item.key === currentPage
const sidebarItem = (
<a
onClick={trackCallback('sidebar: ' + item.name)}
className={clsx(
isCurrentPage
? 'bg-gray-200 text-gray-900'
: 'text-gray-600 hover:bg-gray-100',
'group flex items-center rounded-md px-3 py-2 text-sm font-medium'
)}
aria-current={item.href == currentPage ? 'page' : undefined}
>
{item.icon && (
<item.icon
className={clsx(
isCurrentPage
? 'text-gray-500'
: 'text-gray-400 group-hover:text-gray-500',
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
)}
aria-hidden="true"
/>
)}
<span className="truncate">{item.name}</span>
</a>
)
if (item.href) {
return (
<Link href={item.href} key={item.name}>
{sidebarItem}
</Link>
)
} else {
return onClick ? (
<button onClick={() => onClick(item.key ?? '#')}>{sidebarItem}</button>
) : (
<> </>
)
}
}
function SidebarButton(props: {

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
import { PAST_BETS } from 'common/user'
export function LoansModal(props: {
isOpen: boolean
@ -11,7 +12,7 @@ export function LoansModal(props: {
<Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🏦</span>
<span className="text-xl">Daily loans on your trades</span>
<span className="text-xl">Daily loans on your {PAST_BETS}</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are daily loans?</span>
<span className={'ml-2'}>

View File

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

View File

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

View File

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

View File

@ -35,6 +35,8 @@ import {
import { REFERRAL_AMOUNT } from 'common/economy'
import { LoansModal } from './profile/loans-modal'
import { UserLikesButton } from 'web/components/profile/user-likes-button'
import { PAST_BETS } from 'common/user'
import { capitalize } from 'lodash'
export function UserPage(props: { user: User }) {
const { user } = props
@ -240,7 +242,8 @@ export function UserPage(props: { user: User }) {
<SiteLink href="/referrals">
Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend!
</SiteLink>{' '}
You have <ReferralsButton user={user} currentUser={currentUser} />
You've gotten{' '}
<ReferralsButton user={user} currentUser={currentUser} />
</span>
<ShareIconButton
copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`}
@ -269,7 +272,7 @@ export function UserPage(props: { user: User }) {
),
},
{
title: 'Trades',
title: capitalize(PAST_BETS),
content: (
<>
<BetsList user={user} />

View File

@ -7,10 +7,11 @@ import {
listenForInactiveContracts,
getUserBetContracts,
getUserBetContractsQuery,
listAllContracts,
trendingContractsQuery,
getContractsQuery,
} from 'web/lib/firebase/contracts'
import { useQueryClient } from 'react-query'
import { QueryClient, useQueryClient } from 'react-query'
import { MINUTE_MS } from 'common/util/time'
import { query, limit } from 'firebase/firestore'
import { Sort } from 'web/components/contract-search'
@ -25,6 +26,12 @@ export const useContracts = () => {
return contracts
}
const q = new QueryClient()
export const getCachedContracts = async () =>
q.fetchQuery(['contracts'], () => listAllContracts(1000), {
staleTime: Infinity,
})
export const useTrendingContracts = (maxContracts: number) => {
const result = useFirestoreQueryData(
['trending-contracts', maxContracts],

View File

@ -0,0 +1,6 @@
import { useWindowSize } from 'web/hooks/use-window-size'
export function useIsMobile() {
const { width } = useWindowSize()
return (width ?? 0) < 600
}

View File

@ -98,7 +98,7 @@ export function groupNotifications(notifications: Notification[]) {
const notificationGroup: NotificationGroup = {
notifications: notificationsForContractId,
groupedById: contractId,
isSeen: notificationsForContractId[0].isSeen,
isSeen: notificationsForContractId.some((n) => !n.isSeen),
timePeriod: day,
type: 'normal',
}

View File

@ -0,0 +1,19 @@
export default function CornerDownRightIcon(props: { className?: string }) {
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"
className={props.className}
>
<polyline points="15 10 20 15 15 20"></polyline>
<path d="M4 4v7a4 4 0 0 0 4 4h12"></path>
</svg>
)
}

View File

@ -3,29 +3,33 @@ 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
async function postToBot(url: string, body: unknown) {
const result = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await result.json()
if (!result.ok) {
throw new Error(json.message)
} else {
return json
}
}
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 response = await postToBot(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, {
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())]
return [response.twitchAuthURL, responseFetch.then((r) => r.json())]
}
export async function linkTwitchAccountRedirect(
@ -38,4 +42,34 @@ export async function linkTwitchAccountRedirect(
const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey)
window.location.href = twitchAuthURL
await new Promise((r) => setTimeout(r, 1e10)) // Wait "forever" for the page to change location
}
export async function updateBotEnabledForUser(
privateUser: PrivateUser,
botEnabled: boolean
) {
if (botEnabled) {
return postToBot(`${TWITCH_BOT_PUBLIC_URL}/registerchanneltwitch`, {
apiKey: privateUser.apiKey,
}).then((r) => {
if (!r.success) throw new Error(r.message)
})
} else {
return postToBot(`${TWITCH_BOT_PUBLIC_URL}/unregisterchanneltwitch`, {
apiKey: privateUser.apiKey,
}).then((r) => {
if (!r.success) throw new Error(r.message)
})
}
}
export function getOverlayURLForUser(privateUser: PrivateUser) {
const controlToken = privateUser?.twitchInfo?.controlToken
return `${TWITCH_BOT_PUBLIC_URL}/overlay?t=${controlToken}`
}
export function getDockURLForUser(privateUser: PrivateUser) {
const controlToken = privateUser?.twitchInfo?.controlToken
return `${TWITCH_BOT_PUBLIC_URL}/dock?t=${controlToken}`
}

View File

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

View File

@ -37,7 +37,6 @@ import { User } from 'common/user'
import { ContractComment } from 'common/comment'
import { getOpenGraphProps } from 'common/contract-details'
import { ContractDescription } from 'web/components/contract/contract-description'
import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row'
import {
ContractLeaderboard,
ContractTopTrades,
@ -45,6 +44,8 @@ import {
import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { Title } from 'web/components/title'
import { usePrefetch } from 'web/hooks/use-prefetch'
import { useAdmin } from 'web/hooks/use-admin'
import dayjs from 'dayjs'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
@ -110,19 +111,28 @@ export default function ContractPage(props: {
)
}
// requires an admin to resolve a week after market closes
export function needsAdminToResolve(contract: Contract) {
return !contract.isResolved && dayjs().diff(contract.closeTime, 'day') > 7
}
export function ContractPageSidebar(props: {
user: User | null | undefined
contract: Contract
}) {
const { contract, user } = props
const { creatorId, isResolved, outcomeType } = contract
const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const isNumeric = outcomeType === 'NUMERIC'
const allowTrade = tradingAllowed(contract)
const allowResolve = !isResolved && isCreator && !!user
const isAdmin = useAdmin()
const allowResolve =
!isResolved &&
(isCreator || (needsAdminToResolve(contract) && isAdmin)) &&
!!user
const hasSidePanel =
(isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve)
@ -139,9 +149,19 @@ export function ContractPageSidebar(props: {
))}
{allowResolve &&
(isNumeric || isPseudoNumeric ? (
<NumericResolutionPanel creator={user} contract={contract} />
<NumericResolutionPanel
isAdmin={isAdmin}
creator={user}
isCreator={isCreator}
contract={contract}
/>
) : (
<ResolutionPanel creator={user} contract={contract} />
<ResolutionPanel
isAdmin={isAdmin}
creator={user}
isCreator={isCreator}
contract={contract}
/>
))}
</Col>
) : null
@ -154,10 +174,8 @@ export function ContractPageContent(
}
) {
const { backToHome, comments, user } = props
const contract = useContractWithPreload(props.contract) ?? props.contract
usePrefetch(user?.id)
useTracking(
'view market',
{
@ -238,7 +256,6 @@ export function ContractPageContent(
)}
<ContractOverview contract={contract} bets={nonChallengeBets} />
<ExtraContractActionsRow contract={contract} />
<ContractDescription className="mb-6 px-2" contract={contract} />
{outcomeType === 'NUMERIC' && (

View File

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

View File

@ -11,7 +11,7 @@ import {
NumericResolutionOrExpectation,
PseudoNumericResolutionOrExpectation,
} from 'web/components/contract/contract-card'
import { ContractDetails } from 'web/components/contract/contract-details'
import { MarketSubheader } from 'web/components/contract/contract-details'
import { ContractProbGraph } from 'web/components/contract/contract-prob-graph'
import { NumericGraph } from 'web/components/contract/numeric-graph'
import { Col } from 'web/components/layout/col'
@ -102,50 +102,40 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
return (
<Col className="h-[100vh] w-full bg-white">
<div className="relative flex flex-col pt-2">
<div className="px-3 text-xl text-indigo-700 md:text-2xl">
<Row className="justify-between gap-4 px-2">
<div className="text-xl text-indigo-700 md:text-2xl">
<SiteLink href={href}>{question}</SiteLink>
</div>
{isBinary && (
<BinaryResolutionOrChance contract={contract} probAfter={probAfter} />
)}
<Spacer h={3} />
{isPseudoNumeric && (
<PseudoNumericResolutionOrExpectation contract={contract} />
)}
<Row className="items-center justify-between gap-4 px-2">
<ContractDetails contract={contract} disabled />
{outcomeType === 'FREE_RESPONSE' && (
<FreeResponseResolutionOrChance contract={contract} truncate="long" />
)}
{(isBinary || isPseudoNumeric) &&
tradingAllowed(contract) &&
!betPanelOpen && (
<Button color="gradient" onClick={() => setBetPanelOpen(true)}>
Bet
</Button>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation contract={contract} />
)}
</Row>
<Spacer h={3} />
<Row className="items-center justify-between gap-4 px-2">
<MarketSubheader contract={contract} disabled />
{isBinary && (
<BinaryResolutionOrChance
contract={contract}
probAfter={probAfter}
className="items-center"
/>
{(isBinary || isPseudoNumeric) &&
tradingAllowed(contract) &&
!betPanelOpen && (
<Button color="gradient" onClick={() => setBetPanelOpen(true)}>
Predict
</Button>
)}
</Row>
{isPseudoNumeric && (
<PseudoNumericResolutionOrExpectation contract={contract} />
)}
{outcomeType === 'FREE_RESPONSE' && (
<FreeResponseResolutionOrChance
contract={contract}
truncate="long"
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation contract={contract} />
)}
</Row>
<Spacer h={2} />
</div>
<Spacer h={2} />
{(isBinary || isPseudoNumeric) && betPanelOpen && (
<BetInline

View File

@ -148,6 +148,7 @@ function SearchSection(props: {
defaultPill={pill}
noControls
maxResults={6}
headerClassName="sticky"
persistPrefix={`experimental-home-${sort}`}
/>
</Col>

View File

@ -1,10 +1,9 @@
import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { toast } from 'react-hot-toast'
import { toast, Toaster } from 'react-hot-toast'
import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Page } from 'web/components/page'
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
import {
addContractToGroup,
@ -30,7 +29,7 @@ import Custom404 from '../../404'
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 { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { ContractSearch } from 'web/components/contract-search'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
@ -49,7 +48,11 @@ 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 { GroupNavBar } from 'web/components/nav/group-nav-bar'
import { ArrowLeftIcon } from '@heroicons/react/solid'
import { GroupSidebar } from 'web/components/nav/group-sidebar'
import { SelectMarketsModal } from 'web/components/contract-select-modal'
import { BETTORS } from 'common/user'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -137,6 +140,7 @@ export default function GroupPage(props: {
const user = useUser()
const isAdmin = useAdmin()
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
const [sidebarIndex, setSidebarIndex] = useState(0)
useSaveReferral(user, {
defaultReferrerUsername: creator.username,
@ -150,12 +154,12 @@ export default function GroupPage(props: {
const isMember = user && memberIds.includes(user.id)
const maxLeaderboardSize = 50
const leaderboard = (
const leaderboardPage = (
<Col>
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
<GroupLeaderboard
topUsers={topTraders}
title="🏅 Top traders"
title={`🏅 Top ${BETTORS}`}
header="Profit"
maxToShow={maxLeaderboardSize}
/>
@ -169,7 +173,7 @@ export default function GroupPage(props: {
</Col>
)
const aboutTab = (
const aboutPage = (
<Col>
{(group.aboutPostId != null || isCreator || isAdmin) && (
<GroupAboutPost
@ -189,73 +193,118 @@ export default function GroupPage(props: {
</Col>
)
const questionsTab = (
<ContractSearch
user={user}
defaultSort={'newest'}
defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
/>
const questionsPage = (
<>
{/* align the divs to the right */}
<div className={' flex justify-end px-2 pb-2 sm:hidden'}>
<div>
<JoinOrAddQuestionsButtons
group={group}
user={user}
isMember={!!isMember}
/>
</div>
</div>
<ContractSearch
headerClassName="md:sticky"
user={user}
defaultSort={'newest'}
defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
/>
</>
)
const tabs = [
const sidebarPages = [
{
title: 'Markets',
content: questionsTab,
content: questionsPage,
href: groupPath(group.slug, 'markets'),
key: 'markets',
},
{
title: 'Leaderboards',
content: leaderboard,
content: leaderboardPage,
href: groupPath(group.slug, 'leaderboards'),
key: 'leaderboards',
},
{
title: 'About',
content: aboutTab,
content: aboutPage,
href: groupPath(group.slug, 'about'),
key: 'about',
},
]
const tabIndex = tabs
.map((t) => t.title.toLowerCase())
.indexOf(page ?? 'markets')
const pageContent = sidebarPages[sidebarIndex].content
const onSidebarClick = (key: string) => {
const index = sidebarPages.findIndex((t) => t.key === key)
setSidebarIndex(index)
}
const joinOrAddQuestionsButton = (
<JoinOrAddQuestionsButtons
group={group}
user={user}
isMember={!!isMember}
/>
)
return (
<Page>
<SEO
title={group.name}
description={`Created by ${creator.name}. ${group.about}`}
url={groupPath(group.slug)}
/>
<Col className="relative px-3">
<Row className={'items-center justify-between gap-4'}>
<div className={'sm:mb-1'}>
<div
className={'line-clamp-1 my-2 text-2xl text-indigo-700 sm:my-3'}
>
{group.name}
</div>
<div className={'hidden sm:block'}>
<Linkify text={group.about} />
</div>
</div>
<div className="mt-2">
<JoinOrAddQuestionsButtons
group={group}
user={user}
isMember={!!isMember}
/>
</div>
</Row>
</Col>
<Tabs
currentPageForAnalytics={groupPath(group.slug)}
className={'mx-2 mb-0 sm:mb-2'}
defaultIndex={tabIndex > 0 ? tabIndex : 0}
tabs={tabs}
/>
</Page>
<>
<TopGroupNavBar group={group} />
<div>
<div
className={
'mx-auto w-full pb-[58px] lg:grid lg:grid-cols-12 lg:gap-x-2 lg:pb-0 xl:max-w-7xl xl:gap-x-8'
}
>
<Toaster />
<GroupSidebar
groupName={group.name}
className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex"
onClick={onSidebarClick}
joinOrAddQuestionsButton={joinOrAddQuestionsButton}
currentKey={sidebarPages[sidebarIndex].key}
/>
<SEO
title={group.name}
description={`Created by ${creator.name}. ${group.about}`}
url={groupPath(group.slug)}
/>
<main className={'px-2 pt-1 lg:col-span-8 lg:pt-6 xl:col-span-8'}>
{pageContent}
</main>
</div>
<GroupNavBar
currentPage={sidebarPages[sidebarIndex].key}
onClick={onSidebarClick}
/>
</div>
</>
)
}
export function TopGroupNavBar(props: { group: Group }) {
return (
<header className="sticky top-0 z-50 w-full pb-2 md:hidden lg:col-span-12">
<div className="flex items-center border-b border-gray-200 bg-white px-4">
<div className="flex-shrink-0">
<Link href="/">
<a className="text-indigo-700 hover:text-gray-500 ">
<ArrowLeftIcon className="h-5 w-5" aria-hidden="true" />
</a>
</Link>
</div>
<div className="ml-3">
<h1 className="text-lg font-medium text-indigo-700">
{props.group.name}
</h1>
</div>
</div>
</header>
)
}
@ -263,10 +312,11 @@ function JoinOrAddQuestionsButtons(props: {
group: Group
user: User | null | undefined
isMember: boolean
className?: string
}) {
const { group, user, isMember } = props
return user && isMember ? (
<Row className={'mt-0 justify-end'}>
<Row className={'w-full self-start pt-4'}>
<AddContractButton group={group} user={user} />
</Row>
) : group.anyoneCanJoin ? (
@ -410,9 +460,9 @@ function AddContractButton(props: { group: Group; user: User }) {
return (
<>
<div className={'flex justify-center'}>
<div className={'flex w-full justify-center'}>
<Button
className="whitespace-nowrap"
className="w-full whitespace-nowrap"
size="md"
color="indigo"
onClick={() => setOpen(true)}
@ -467,7 +517,9 @@ function JoinGroupButton(props: {
<div>
<button
onClick={follow}
className={'btn-md btn-outline btn whitespace-nowrap normal-case'}
className={
'btn-md btn-outline btn w-full whitespace-nowrap normal-case'
}
>
Follow
</button>

View File

@ -26,6 +26,7 @@ const Home = () => {
user={user}
persistPrefix="home-search"
useQueryUrlParam={true}
headerClassName="sticky"
/>
</Col>
<button

View File

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

View File

@ -1,7 +1,12 @@
import { ControlledTabs } from 'web/components/layout/tabs'
import React, { useEffect, useMemo, useState } from 'react'
import Router, { useRouter } from 'next/router'
import { Notification, notification_source_types } from 'common/notification'
import {
BetFillData,
ContractResolutionData,
Notification,
notification_source_types,
} from 'common/notification'
import { Avatar, EmptyAvatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
@ -141,6 +146,7 @@ function RenderNotificationGroups(props: {
<NotificationItem
notification={notification.notifications[0]}
key={notification.notifications[0].id}
justSummary={false}
/>
) : (
<NotificationGroupItem
@ -697,20 +703,11 @@ function NotificationGroupItem(props: {
function NotificationItem(props: {
notification: Notification
justSummary?: boolean
justSummary: boolean
isChildOfGroup?: boolean
}) {
const { notification, justSummary, isChildOfGroup } = props
const {
sourceType,
sourceUserName,
sourceUserAvatarUrl,
sourceUpdateType,
reasonText,
reason,
sourceUserUsername,
sourceText,
} = notification
const { sourceType, reason, sourceUpdateType } = notification
const [highlighted] = useState(!notification.isSeen)
@ -718,39 +715,113 @@ function NotificationItem(props: {
setNotificationsAsSeen([notification])
}, [notification])
const questionNeedsResolution = sourceUpdateType == 'closed'
// TODO Any new notification should be its own component
if (reason === 'bet_fill') {
return (
<BetFillNotification
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
justSummary={justSummary}
/>
)
} else if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
return (
<ContractResolvedNotification
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
justSummary={justSummary}
/>
)
}
// TODO Add new notification components here
if (justSummary) {
return (
<Row className={'items-center text-sm text-gray-500 sm:justify-start'}>
<div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}>
<div className={'flex pl-1 sm:pl-0'}>
<UserLink
name={sourceUserName || ''}
username={sourceUserUsername || ''}
className={'mr-0 flex-shrink-0'}
short={true}
/>
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
<span className={'flex-shrink-0'}>
{sourceType &&
reason &&
getReasonForShowingNotification(notification, true)}
</span>
<div className={'ml-1 text-black'}>
<NotificationTextLabel
className={'line-clamp-1'}
notification={notification}
justSummary={true}
/>
</div>
</div>
</div>
</div>
</Row>
<NotificationSummaryFrame
notification={notification}
subtitle={
(sourceType &&
reason &&
getReasonForShowingNotification(notification, true)) ??
''
}
>
<NotificationTextLabel
className={'line-clamp-1'}
notification={notification}
justSummary={true}
/>
</NotificationSummaryFrame>
)
}
return (
<NotificationFrame
notification={notification}
subtitle={getReasonForShowingNotification(
notification,
isChildOfGroup ?? false
)}
highlighted={highlighted}
>
<div className={'mt-1 ml-1 md:text-base'}>
<NotificationTextLabel notification={notification} />
</div>
</NotificationFrame>
)
}
function NotificationSummaryFrame(props: {
notification: Notification
subtitle: string
children: React.ReactNode
}) {
const { notification, subtitle, children } = props
const { sourceUserName, sourceUserUsername } = notification
return (
<Row className={'items-center text-sm text-gray-500 sm:justify-start'}>
<div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}>
<div className={'flex pl-1 sm:pl-0'}>
<UserLink
name={sourceUserName || ''}
username={sourceUserUsername || ''}
className={'mr-0 flex-shrink-0'}
short={true}
/>
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
<span className={'flex-shrink-0'}>{subtitle}</span>
<div className={'line-clamp-1 ml-1 text-black'}>{children}</div>
</div>
</div>
</div>
</Row>
)
}
function NotificationFrame(props: {
notification: Notification
highlighted: boolean
subtitle: string
children: React.ReactNode
isChildOfGroup?: boolean
}) {
const { notification, isChildOfGroup, highlighted, subtitle, children } =
props
const {
sourceType,
sourceUserName,
sourceUserAvatarUrl,
sourceUpdateType,
reason,
reasonText,
sourceUserUsername,
sourceText,
} = notification
const questionNeedsResolution = sourceUpdateType == 'closed'
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 600
return (
<div
className={clsx(
@ -796,18 +867,13 @@ function NotificationItem(props: {
}
>
<div>
{!questionNeedsResolution && (
<UserLink
name={sourceUserName || ''}
username={sourceUserUsername || ''}
className={'relative mr-1 flex-shrink-0'}
short={true}
/>
)}
{getReasonForShowingNotification(
notification,
isChildOfGroup ?? false
)}
<UserLink
name={sourceUserName || ''}
username={sourceUserUsername || ''}
className={'relative mr-1 flex-shrink-0'}
short={isMobile}
/>
{subtitle}
{isChildOfGroup ? (
<RelativeTimestamp time={notification.createdTime} />
) : (
@ -822,9 +888,7 @@ function NotificationItem(props: {
)}
</div>
</Row>
<div className={'mt-1 ml-1 md:text-base'}>
<NotificationTextLabel notification={notification} />
</div>
<div className={'mt-1 ml-1 md:text-base'}>{children}</div>
<div className={'mt-6 border-b border-gray-300'} />
</div>
@ -832,6 +896,148 @@ function NotificationItem(props: {
)
}
function BetFillNotification(props: {
notification: Notification
highlighted: boolean
justSummary: boolean
isChildOfGroup?: boolean
}) {
const { notification, isChildOfGroup, highlighted, justSummary } = props
const { sourceText, data } = notification
const { creatorOutcome, probability, limitOrderTotal, limitOrderRemaining } =
(data as BetFillData) ?? {}
const subtitle = 'bet against you'
const amount = formatMoney(parseInt(sourceText ?? '0'))
const description =
creatorOutcome && probability ? (
<span>
of your {limitOrderTotal ? formatMoney(limitOrderTotal) : ''}
<span
className={clsx(
'mx-1',
creatorOutcome === 'YES'
? 'text-primary'
: creatorOutcome === 'NO'
? 'text-red-500'
: 'text-blue-500'
)}
>
{creatorOutcome}
</span>
limit order at {Math.round(probability * 100)}% was filled{' '}
{limitOrderRemaining
? `(${formatMoney(limitOrderRemaining)} remaining)`
: ''}
</span>
) : (
<span>of your limit order was filled</span>
)
if (justSummary) {
return (
<NotificationSummaryFrame notification={notification} subtitle={subtitle}>
<Row className={'line-clamp-1'}>
<span className={'text-primary mr-1'}>{amount}</span>
<span>{description}</span>
</Row>
</NotificationSummaryFrame>
)
}
return (
<NotificationFrame
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
subtitle={subtitle}
>
<Row>
<span>
<span className="text-primary mr-1">{amount}</span>
{description}
</span>
</Row>
</NotificationFrame>
)
}
function ContractResolvedNotification(props: {
notification: Notification
highlighted: boolean
justSummary: boolean
isChildOfGroup?: boolean
}) {
const { notification, isChildOfGroup, highlighted, justSummary } = props
const { sourceText, data } = notification
const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {}
const subtitle = 'resolved the market'
const resolutionDescription = () => {
if (!sourceText) return <div />
if (sourceText === 'YES' || sourceText == 'NO') {
return <BinaryOutcomeLabel outcome={sourceText as any} />
}
if (sourceText.includes('%'))
return <ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} />
if (sourceText === 'CANCEL') return <CancelLabel />
if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel />
// Numeric market
if (parseFloat(sourceText))
return <NumericValueLabel value={parseFloat(sourceText)} />
// Free response market
return (
<div className={'line-clamp-1 text-blue-400'}>
<Linkify text={sourceText} />
</div>
)
}
const description =
userInvestment && userPayout !== undefined ? (
<Row className={'gap-1 '}>
{resolutionDescription()}
Invested:
<span className={'text-primary'}>{formatMoney(userInvestment)} </span>
Payout:
<span
className={clsx(
userPayout > 0 ? 'text-primary' : 'text-red-500',
'truncate'
)}
>
{formatMoney(userPayout)}
{` (${userPayout > 0 ? '+' : ''}${Math.round(
((userPayout - userInvestment) / userInvestment) * 100
)}%)`}
</span>
</Row>
) : (
<span>{resolutionDescription()}</span>
)
if (justSummary) {
return (
<NotificationSummaryFrame notification={notification} subtitle={subtitle}>
<Row className={'line-clamp-1'}>{description}</Row>
</NotificationSummaryFrame>
)
}
return (
<NotificationFrame
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
subtitle={subtitle}
>
<Row>
<span>{description}</span>
</Row>
</NotificationFrame>
)
}
export const setNotificationsAsSeen = async (notifications: Notification[]) => {
const unseenNotifications = notifications.filter((n) => !n.isSeen)
return await Promise.all(
@ -951,30 +1157,7 @@ function NotificationTextLabel(props: {
if (sourceType === 'contract') {
if (justSummary || !sourceText) return <div />
// Resolved contracts
if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
{
if (sourceText === 'YES' || sourceText == 'NO') {
return <BinaryOutcomeLabel outcome={sourceText as any} />
}
if (sourceText.includes('%'))
return (
<ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} />
)
if (sourceText === 'CANCEL') return <CancelLabel />
if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel />
// Numeric market
if (parseFloat(sourceText))
return <NumericValueLabel value={parseFloat(sourceText)} />
// Free response market
return (
<div className={className ? className : 'line-clamp-1 text-blue-400'}>
<Linkify text={sourceText} />
</div>
)
}
}
// Close date will be a number - it looks better without it
if (sourceUpdateType === 'closed') {
return <div />
@ -1002,15 +1185,6 @@ function NotificationTextLabel(props: {
return (
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
)
} else if (sourceType === 'bet' && sourceText) {
return (
<>
<span className="text-primary">
{formatMoney(parseInt(sourceText))}
</span>{' '}
<span>of your limit order was filled</span>
</>
)
} else if (sourceType === 'challenge' && sourceText) {
return (
<>
@ -1074,9 +1248,6 @@ function getReasonForShowingNotification(
else if (sourceSlug) reasonText = 'joined because you shared'
else reasonText = 'joined because of you'
break
case 'bet':
reasonText = 'bet against you'
break
case 'challenge':
reasonText = 'accepted your challenge'
break

View File

@ -1,24 +1,28 @@
import React, { useState } from 'react'
import { RefreshIcon } from '@heroicons/react/outline'
import { AddFundsButton } from 'web/components/add-funds-button'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title'
import { formatMoney } from 'common/util/format'
import { PrivateUser, User } from 'common/user'
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
import { changeUserInfo } from 'web/lib/firebase/api'
import { uploadImage } from 'web/lib/firebase/storage'
import { formatMoney } from 'common/util/format'
import Link from 'next/link'
import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { AddFundsButton } from 'web/components/add-funds-button'
import { ConfirmationButton } from 'web/components/confirmation-button'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { User, PrivateUser } from 'common/user'
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
import { defaultBannerUrl } from 'web/components/user-page'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { SiteLink } from 'web/components/site-link'
import Textarea from 'react-expanding-textarea'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { Title } from 'web/components/title'
import { defaultBannerUrl } from 'web/components/user-page'
import { generateNewApiKey } from 'web/lib/api/api-key'
import { TwitchPanel } from 'web/components/profile/twitch-panel'
import { changeUserInfo } from 'web/lib/firebase/api'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { uploadImage } from 'web/lib/firebase/storage'
import {
getUserAndPrivateUser,
updatePrivateUser,
updateUser,
} from 'web/lib/firebase/users'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
@ -93,10 +97,15 @@ export default function ProfilePage(props: {
}
}
const updateApiKey = async (e: React.MouseEvent) => {
const updateApiKey = async (e?: React.MouseEvent) => {
const newApiKey = await generateNewApiKey(user.id)
setApiKey(newApiKey ?? '')
e.preventDefault()
e?.preventDefault()
if (!privateUser.twitchInfo) return
await updatePrivateUser(privateUser.id, {
twitchInfo: { ...privateUser.twitchInfo, needsRelinking: true },
})
}
const fileHandler = async (event: any) => {
@ -229,16 +238,38 @@ export default function ProfilePage(props: {
value={apiKey}
readOnly
/>
<button
className="btn btn-primary btn-square p-2"
onClick={updateApiKey}
<ConfirmationButton
openModalBtn={{
className: 'btn btn-primary btn-square p-2',
label: '',
icon: <RefreshIcon />,
}}
submitBtn={{
label: 'Update key',
className: 'btn-primary',
}}
onSubmitWithSuccess={async () => {
updateApiKey()
return true
}}
>
<RefreshIcon />
</button>
<Col>
<Title text={'Are you sure?'} />
<div>
Updating your API key will break any existing applications
connected to your account, <b>including the Twitch bot</b>.
You will need to go to the{' '}
<Link href="/twitch">
<a className="underline focus:outline-none">
Twitch page
</a>
</Link>{' '}
to relink your account.
</div>
</Col>
</ConfirmationButton>
</div>
</div>
<TwitchPanel />
</Col>
</Col>
</Page>

View File

@ -13,6 +13,8 @@ import { SiteLink } from 'web/components/site-link'
import { Linkify } from 'web/components/linkify'
import { getStats } from 'web/lib/firebase/stats'
import { Stats } from 'common/stats'
import { PAST_BETS } from 'common/user'
import { capitalize } from 'lodash'
export default function Analytics() {
const [stats, setStats] = useState<Stats | undefined>(undefined)
@ -156,7 +158,7 @@ export function CustomAnalytics(props: {
defaultIndex={0}
tabs={[
{
title: 'Trades',
title: capitalize(PAST_BETS),
content: (
<DailyCountChart
dailyCounts={dailyBetCounts}

View File

@ -76,6 +76,13 @@ const Salem = {
}
const tourneys: Tourney[] = [
{
title: 'Clearer Thinking Regrant Project',
blurb: 'Which projects will Clearer Thinking give a grant to?',
award: '$13,000',
endTime: toDate('Sep 22, 2022'),
groupId: 'fhksfIgqyWf7OxsV9nkM',
},
{
title: 'Manifold F2P Tournament',
blurb:
@ -99,13 +106,6 @@ const tourneys: Tourney[] = [
endTime: toDate('Jan 6, 2023'),
groupId: 'SxGRqXRpV3RAQKudbcNb',
},
// {
// title: 'Clearer Thinking Regrant Project',
// blurb: 'Something amazing',
// award: '$10,000',
// endTime: toDate('Sep 22, 2022'),
// groupId: '2VsVVFGhKtIdJnQRAXVb',
// },
// Tournaments without awards get featured belows
{

View File

@ -1,28 +1,48 @@
import { useState } from 'react'
import { LinkIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { PrivateUser, User } from 'common/user'
import Link from 'next/link'
import { MouseEventHandler, ReactNode, 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'
import { Button } from 'web/components/button'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Spacer } from 'web/components/layout/spacer'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { ManifoldLogo } from 'web/components/nav/manifold-logo'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { useTracking } from 'web/hooks/use-tracking'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import {
firebaseLogin,
getUserAndPrivateUser,
updatePrivateUser,
} from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import {
getDockURLForUser,
getOverlayURLForUser,
linkTwitchAccountRedirect,
updateBotEnabledForUser,
} from 'web/lib/twitch/link-twitch-account'
import { copyToClipboard } from 'web/lib/util/copy'
export default function TwitchLandingPage() {
useSaveReferral()
useTracking('view twitch landing page')
function ButtonGetStarted(props: {
user?: User | null
privateUser?: PrivateUser | null
buttonClass?: string
spinnerClass?: string
}) {
const { user, privateUser, buttonClass, spinnerClass } = props
const user = useUser()
const privateUser = usePrivateUser()
const twitchUser = privateUser?.twitchInfo?.twitchName
const [isLoading, setLoading] = useState(false)
const needsRelink =
privateUser?.twitchInfo?.twitchName &&
privateUser?.twitchInfo?.needsRelinking
const callback =
user && privateUser
@ -34,11 +54,11 @@ export default function TwitchLandingPage() {
const { user, privateUser } = await getUserAndPrivateUser(userId)
if (!user || !privateUser) return
if (privateUser.twitchInfo?.twitchName) return // If we've already linked Twitch, no need to do so again
await linkTwitchAccountRedirect(user, privateUser)
}
const [isLoading, setLoading] = useState(false)
const getStarted = async () => {
try {
setLoading(true)
@ -49,9 +69,335 @@ export default function TwitchLandingPage() {
} catch (e) {
console.error(e)
toast.error('Failed to sign up. Please try again later.')
} finally {
setLoading(false)
}
}
return isLoading ? (
<LoadingIndicator
spinnerClassName={clsx('!w-11 !h-11 my-4', spinnerClass)}
/>
) : (
<Button
size="xl"
color={needsRelink ? 'red' : 'gradient'}
className={clsx('my-4 self-center !px-16', buttonClass)}
onClick={getStarted}
>
{needsRelink ? 'API key updated: relink Twitch' : 'Start playing'}
</Button>
)
}
function TwitchPlaysManifoldMarkets(props: {
user?: User | null
privateUser?: PrivateUser | null
}) {
const { user, privateUser } = props
const twitchInfo = privateUser?.twitchInfo
const twitchUser = twitchInfo?.twitchName
return (
<div>
<Row className="mb-4">
<img
src="/twitch-glitch.svg"
className="mb-[0.4rem] mr-4 inline h-10 w-10"
></img>
<Title
text={'Twitch plays Manifold Markets'}
className={'!-my-0 md:block'}
/>
</Row>
<Col className="gap-4">
<div>
Similar to Twitch channel point predictions, Manifold Markets allows
you to create and feature on stream any question you like with users
predicting to earn play money.
</div>
<div>
The key difference is that Manifold's questions function more like a
stock market and viewers can buy and sell shares over the course of
the event and not just at the start. The market will eventually
resolve to yes or no at which point the winning shareholders will
receive their profit.
</div>
Start playing now by logging in with Google and typing commands in chat!
{twitchUser && !twitchInfo.needsRelinking ? (
<Button
size="xl"
color="green"
className="btn-disabled my-4 self-center !border-none"
>
Account connected: {twitchUser}
</Button>
) : (
<ButtonGetStarted user={user} privateUser={privateUser} />
)}
<div>
Instead of Twitch channel points we use our play money, Mana (M$). All
viewers start with M$1000 and more can be earned for free and then{' '}
<Link href="/charity">
<a className="underline">donated to a charity</a>
</Link>{' '}
of their choice at no cost!
</div>
</Col>
</div>
)
}
function Subtitle(props: { text: string }) {
const { text } = props
return <div className="text-2xl">{text}</div>
}
function Command(props: { command: string; desc: string }) {
const { command, desc } = props
return (
<div>
<p className="inline font-bold">{'!' + command}</p>
{' - '}
<p className="inline">{desc}</p>
</div>
)
}
function TwitchChatCommands() {
return (
<div>
<Title text="Twitch Chat Commands" className="md:block" />
<Col className="gap-4">
<Subtitle text="For Chat" />
<Command command="bet yes#" desc="Bets a # of Mana on yes." />
<Command command="bet no#" desc="Bets a # of Mana on no." />
<Command
command="sell"
desc="Sells all shares you own. Using this command causes you to
cash out early before the market resolves. This could be profitable
(if the probability has moved towards the direction you bet) or cause
a loss, although at least you keep some Mana. For maximum profit (but
also risk) it is better to not sell and wait for a favourable
resolution."
/>
<Command command="balance" desc="Shows how much Mana you own." />
<Command command="allin yes" desc="Bets your entire balance on yes." />
<Command command="allin no" desc="Bets your entire balance on no." />
<Subtitle text="For Mods/Streamer" />
<Command
command="create <question>"
desc="Creates and features the question. Be careful... this will override any question that is currently featured."
/>
<Command command="resolve yes" desc="Resolves the market as 'Yes'." />
<Command command="resolve no" desc="Resolves the market as 'No'." />
<Command
command="resolve n/a"
desc="Resolves the market as 'N/A' and refunds everyone their Mana."
/>
</Col>
</div>
)
}
function BotSetupStep(props: {
stepNum: number
buttonName?: string
buttonOnClick?: MouseEventHandler
overrideButton?: ReactNode
children: ReactNode
}) {
const { stepNum, buttonName, buttonOnClick, overrideButton, children } = props
return (
<Col className="flex-1">
{(overrideButton || buttonName) && (
<>
{overrideButton ?? (
<Button
size={'md'}
color={'green'}
className="!border-none"
onClick={buttonOnClick}
>
{buttonName}
</Button>
)}
<Spacer h={4} />
</>
)}
<div>
<p className="inline font-bold">Step {stepNum}. </p>
{children}
</div>
</Col>
)
}
function BotConnectButton(props: {
privateUser: PrivateUser | null | undefined
}) {
const { privateUser } = props
const [loading, setLoading] = useState(false)
const updateBotConnected = (connected: boolean) => async () => {
if (!privateUser) return
const twitchInfo = privateUser.twitchInfo
if (!twitchInfo) return
const error = connected
? 'Failed to add bot to your channel'
: 'Failed to remove bot from your channel'
const success = connected
? 'Added bot to your channel'
: 'Removed bot from your channel'
setLoading(true)
toast.promise(
updateBotEnabledForUser(privateUser, connected)
.then(() =>
updatePrivateUser(privateUser.id, {
twitchInfo: { ...twitchInfo, botEnabled: connected },
})
)
.finally(() => setLoading(false)),
{ loading: 'Updating bot settings...', error, success },
{
loading: {
className: '!max-w-sm',
},
success: {
className:
'!bg-primary !transition-all !duration-500 !text-white !max-w-sm',
},
error: {
className:
'!bg-red-400 !transition-all !duration-500 !text-white !max-w-sm',
},
}
)
}
return (
<>
{privateUser?.twitchInfo?.botEnabled ? (
<Button
color="red"
onClick={updateBotConnected(false)}
className={clsx(loading && '!btn-disabled', 'border-none')}
>
{loading ? (
<LoadingIndicator spinnerClassName="!h-5 !w-5 border-white !border-2" />
) : (
'Remove bot from channel'
)}
</Button>
) : (
<Button
color="green"
onClick={updateBotConnected(true)}
className={clsx(loading && '!btn-disabled', 'border-none')}
>
{loading ? (
<LoadingIndicator spinnerClassName="!h-5 !w-5 border-white !border-2" />
) : (
'Add bot to your channel'
)}
</Button>
)}
</>
)
}
function SetUpBot(props: {
user?: User | null
privateUser?: PrivateUser | null
}) {
const { user, privateUser } = props
const twitchLinked =
privateUser?.twitchInfo?.twitchName &&
!privateUser?.twitchInfo?.needsRelinking
? true
: undefined
const toastTheme = {
className: '!bg-primary !text-white',
icon: <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />,
}
const copyOverlayLink = async () => {
if (!privateUser) return
copyToClipboard(getOverlayURLForUser(privateUser))
toast.success('Overlay link copied!', toastTheme)
}
const copyDockLink = async () => {
if (!privateUser) return
copyToClipboard(getDockURLForUser(privateUser))
toast.success('Dock link copied!', toastTheme)
}
return (
<>
<Title
text={'Set up the bot for your own stream'}
className={'!mb-4 md:block'}
/>
<Col className="gap-4">
<img
src="https://raw.githubusercontent.com/PhilBladen/ManifoldTwitchIntegration/master/docs/OBS.png" // TODO: Copy this into the Manifold codebase public folder
className="!-my-2"
></img>
To add the bot to your stream make sure you have logged in then follow
the steps below.
{!twitchLinked && (
<ButtonGetStarted
user={user}
privateUser={privateUser}
buttonClass={'!my-0'}
spinnerClass={'!my-0'}
/>
)}
<div className="flex flex-col gap-6 sm:flex-row">
<BotSetupStep
stepNum={1}
overrideButton={
twitchLinked && <BotConnectButton privateUser={privateUser} />
}
>
Use the button above to add the bot to your channel. Then mod it by
typing in your Twitch chat: <b>/mod ManifoldBot</b>
<br />
If the bot is not modded it will not be able to respond to commands
properly.
</BotSetupStep>
<BotSetupStep
stepNum={2}
buttonName={twitchLinked && 'Overlay link'}
buttonOnClick={copyOverlayLink}
>
Create a new browser source in your streaming software such as OBS.
Paste in the above link and resize it to your liking. We recommend
setting the size to 400x400.
</BotSetupStep>
<BotSetupStep
stepNum={3}
buttonName={twitchLinked && 'Control dock link'}
buttonOnClick={copyDockLink}
>
The bot can be controlled entirely through chat. But we made an easy
to use control panel. Share the link with your mods or embed it into
your OBS as a custom dock.
</BotSetupStep>
</div>
</Col>
</>
)
}
export default function TwitchLandingPage() {
useSaveReferral()
useTracking('view twitch landing page')
const user = useUser()
const privateUser = usePrivateUser()
return (
<Page>
@ -62,58 +408,11 @@ export default function TwitchLandingPage() {
<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 className="max-w-3xl gap-8 rounded bg-white p-4 text-gray-600 shadow-md sm:mx-auto sm:p-10">
<TwitchPlaysManifoldMarkets user={user} privateUser={privateUser} />
<TwitchChatCommands />
<SetUpBot user={user} privateUser={privateUser} />
</Col>
</Page>
)

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 2400 2800" style="enable-background:new 0 0 2400 2800;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#9146FF;}
</style>
<title>Asset 2</title>
<g>
<polygon class="st0" points="2200,1300 1800,1700 1400,1700 1050,2050 1050,1700 600,1700 600,200 2200,200 "/>
<g>
<g id="Layer_1-2">
<path class="st1" d="M500,0L0,500v1800h600v500l500-500h400l900-900V0H500z M2200,1300l-400,400h-400l-350,350v-350H600V200h1600
V1300z"/>
<rect x="1700" y="550" class="st1" width="200" height="600"/>
<rect x="1150" y="550" class="st1" width="200" height="600"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 890 B

View File

@ -26,6 +26,8 @@ module.exports = {
'greyscale-5': '#9191A7',
'greyscale-6': '#66667C',
'greyscale-7': '#111140',
'highlight-blue': '#5BCEFF',
'hover-blue': '#90DEFF',
},
typography: {
quoteless: {