diff --git a/common/calculate.ts b/common/calculate.ts
index da4ce13a..e4c9ed07 100644
--- a/common/calculate.ts
+++ b/common/calculate.ts
@@ -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[]) {
diff --git a/common/envs/constants.ts b/common/envs/constants.ts
index ba460d58..0502322a 100644
--- a/common/envs/constants.ts
+++ b/common/envs/constants.ts
@@ -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)
}
diff --git a/common/envs/prod.ts b/common/envs/prod.ts
index b3b552eb..a9d1ffc3 100644
--- a/common/envs/prod.ts
+++ b/common/envs/prod.ts
@@ -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: [
diff --git a/common/follow.ts b/common/follow.ts
index 04ca6899..7ff6e7f2 100644
--- a/common/follow.ts
+++ b/common/follow.ts
@@ -2,3 +2,8 @@ export type Follow = {
userId: string
timestamp: number
}
+
+export type ContractFollow = {
+ id: string // user id
+ createdTime: number
+}
diff --git a/common/notification.ts b/common/notification.ts
index affa33cb..b42df541 100644
--- a/common/notification.ts
+++ b/common/notification.ts
@@ -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
-> = {
- 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§ion=${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
+}
diff --git a/common/txn.ts b/common/txn.ts
index 00b19570..2b7a32e8 100644
--- a/common/txn.ts
+++ b/common/txn.ts
@@ -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 = {
@@ -23,6 +30,7 @@ export type Txn = {
| '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
diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts
new file mode 100644
index 00000000..3fc0fb2f
--- /dev/null
+++ b/common/user-notification-preferences.ts
@@ -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
+> = {
+ you_referred_user: 'referral_bonuses',
+ user_joined_to_bet_on_your_market: 'referral_bonuses',
+ tip_received: 'tips_on_your_comments',
+ bet_fill: 'limit_order_fills',
+ user_joined_from_your_group_invite: 'referral_bonuses',
+ challenge_accepted: 'limit_order_fills',
+ betting_streak_incremented: 'betting_streaks',
+ liked_and_tipped_your_contract: 'tips_on_your_markets',
+ comment_on_your_contract: 'all_comments_on_my_markets',
+ answer_on_your_contract: 'all_answers_on_my_markets',
+ comment_on_contract_you_follow: 'all_comments_on_watched_markets',
+ answer_on_contract_you_follow: 'all_answers_on_watched_markets',
+ update_on_contract_you_follow: 'market_updates_on_watched_markets',
+ resolution_on_contract_you_follow: 'resolutions_on_watched_markets',
+ comment_on_contract_with_users_shares_in:
+ 'all_comments_on_contracts_with_shares_in_on_watched_markets',
+ answer_on_contract_with_users_shares_in:
+ 'all_answers_on_contracts_with_shares_in_on_watched_markets',
+ update_on_contract_with_users_shares_in:
+ 'market_updates_on_watched_markets_with_shares_in',
+ resolution_on_contract_with_users_shares_in:
+ 'resolutions_on_watched_markets_with_shares_in',
+ comment_on_contract_with_users_answer: 'all_comments_on_watched_markets',
+ update_on_contract_with_users_answer: 'market_updates_on_watched_markets',
+ resolution_on_contract_with_users_answer: 'resolutions_on_watched_markets',
+ answer_on_contract_with_users_answer: 'all_answers_on_watched_markets',
+ comment_on_contract_with_users_comment: 'all_comments_on_watched_markets',
+ answer_on_contract_with_users_comment: 'all_answers_on_watched_markets',
+ update_on_contract_with_users_comment: 'market_updates_on_watched_markets',
+ resolution_on_contract_with_users_comment: 'resolutions_on_watched_markets',
+ reply_to_users_answer: 'all_replies_to_my_answers_on_watched_markets',
+ reply_to_users_comment: 'all_replies_to_my_comments_on_watched_markets',
+}
+
+export const getNotificationDestinationsForUser = (
+ privateUser: PrivateUser,
+ // TODO: accept reasons array from most to least important and work backwards
+ reason: notification_reason_types | notification_preference
+) => {
+ const notificationSettings = privateUser.notificationPreferences
+ let destinations
+ let subscriptionType: notification_preference | undefined
+ if (Object.keys(notificationSettings).includes(reason)) {
+ subscriptionType = reason as notification_preference
+ destinations = notificationSettings[subscriptionType]
+ } else {
+ const key = reason as notification_reason_types
+ subscriptionType = notificationReasonToSubscriptionType[key]
+ destinations = subscriptionType
+ ? notificationSettings[subscriptionType]
+ : []
+ }
+ const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
+ return {
+ sendToEmail: destinations.includes('email'),
+ sendToBrowser: destinations.includes('browser'),
+ unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
+ urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
+ }
+}
diff --git a/common/user.ts b/common/user.ts
index 7bd89906..5ab07d35 100644
--- a/common/user.ts
+++ b/common/user.ts
@@ -1,4 +1,5 @@
-import { filterDefined } from './util/array'
+import { notification_preferences } from './user-notification-preferences'
+import { ENV_CONFIG } from 'common/envs/constants'
export type User = {
id: string
@@ -65,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
diff --git a/firestore.rules b/firestore.rules
index 6f2ea90a..08214b10 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -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'
]
}
diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts
index 34a8f218..038e0142 100644
--- a/functions/src/create-notification.ts
+++ b/functions/src/create-notification.ts
@@ -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(
+ 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'
+ )
+ )
+ )
+}
diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts
index ab5f014a..ab70b4e6 100644
--- a/functions/src/create-user.ts
+++ b/functions/src/create-user.ts
@@ -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)
diff --git a/functions/src/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html
deleted file mode 100644
index c8f6a171..00000000
--- a/functions/src/email-templates/500-mana.html
+++ /dev/null
@@ -1,321 +0,0 @@
-
-
-
-
- Manifold Markets 7th Day Anniversary Gift!
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Thanks for
- using Manifold Markets. Running low
- on mana (M$)? Click the link below to receive a one time gift of M$500!
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Did
- you know, besides making correct predictions, there are
- plenty of other ways to earn mana?
-
-
-
Cheers,
-
-
David
- from Manifold
-
-
-
-
-
-
-
-
-
-
-
Cheers,
-
David from Manifold
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html
index c73f7458..bf163f69 100644
--- a/functions/src/email-templates/creating-market.html
+++ b/functions/src/email-templates/creating-market.html
@@ -494,7 +494,7 @@
click here to manage your notifications .
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/interesting-markets.html b/functions/src/email-templates/interesting-markets.html
index 7c3e653d..0cee6269 100644
--- a/functions/src/email-templates/interesting-markets.html
+++ b/functions/src/email-templates/interesting-markets.html
@@ -443,7 +443,7 @@
click here to manage your notifications .
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/market-answer-comment.html b/functions/src/email-templates/market-answer-comment.html
index a19aa7c3..4b98730f 100644
--- a/functions/src/email-templates/market-answer-comment.html
+++ b/functions/src/email-templates/market-answer-comment.html
@@ -529,7 +529,7 @@
click here to manage your notifications .
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/market-answer.html b/functions/src/email-templates/market-answer.html
index b2d7f727..e3d42b9d 100644
--- a/functions/src/email-templates/market-answer.html
+++ b/functions/src/email-templates/market-answer.html
@@ -369,7 +369,7 @@
click here to manage your notifications .
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html
index ee7976b0..4abd225e 100644
--- a/functions/src/email-templates/market-close.html
+++ b/functions/src/email-templates/market-close.html
@@ -487,7 +487,7 @@
click here to manage your notifications .
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/market-comment.html b/functions/src/email-templates/market-comment.html
index 23e20dac..ce0669f1 100644
--- a/functions/src/email-templates/market-comment.html
+++ b/functions/src/email-templates/market-comment.html
@@ -369,7 +369,7 @@
click here to manage your notifications .
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/market-resolved-no-bets.html b/functions/src/email-templates/market-resolved-no-bets.html
index ff5f541f..5d886adf 100644
--- a/functions/src/email-templates/market-resolved-no-bets.html
+++ b/functions/src/email-templates/market-resolved-no-bets.html
@@ -470,7 +470,7 @@
click here to manage your notifications .
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/market-resolved.html b/functions/src/email-templates/market-resolved.html
index de29a0f1..767202b6 100644
--- a/functions/src/email-templates/market-resolved.html
+++ b/functions/src/email-templates/market-resolved.html
@@ -502,7 +502,7 @@
click here to manage your notifications .
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/new-market-from-followed-user.html b/functions/src/email-templates/new-market-from-followed-user.html
index 877d554f..49633fb2 100644
--- a/functions/src/email-templates/new-market-from-followed-user.html
+++ b/functions/src/email-templates/new-market-from-followed-user.html
@@ -318,7 +318,7 @@
click here to manage your notifications .
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/new-unique-bettor.html b/functions/src/email-templates/new-unique-bettor.html
index 30da8b99..51026121 100644
--- a/functions/src/email-templates/new-unique-bettor.html
+++ b/functions/src/email-templates/new-unique-bettor.html
@@ -376,7 +376,7 @@
click here to manage your notifications .
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/new-unique-bettors.html b/functions/src/email-templates/new-unique-bettors.html
index eb4c04e2..09c44d03 100644
--- a/functions/src/email-templates/new-unique-bettors.html
+++ b/functions/src/email-templates/new-unique-bettors.html
@@ -480,7 +480,7 @@
click here to manage your notifications .
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/one-week.html b/functions/src/email-templates/one-week.html
index b8e233d5..e7d14a7e 100644
--- a/functions/src/email-templates/one-week.html
+++ b/functions/src/email-templates/one-week.html
@@ -283,7 +283,7 @@
click here to manage your notifications .
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/thank-you.html b/functions/src/email-templates/thank-you.html
index 7ac72d0a..beef11ee 100644
--- a/functions/src/email-templates/thank-you.html
+++ b/functions/src/email-templates/thank-you.html
@@ -218,7 +218,7 @@
click here to manage your notifications .
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/welcome.html b/functions/src/email-templates/welcome.html
index dccec695..d6caaa0c 100644
--- a/functions/src/email-templates/welcome.html
+++ b/functions/src/email-templates/welcome.html
@@ -290,7 +290,7 @@
click here to manage your notifications .
+ " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/emails.ts b/functions/src/emails.ts
index bb9f7195..98309ebe 100644
--- a/functions/src/emails.ts
+++ b/functions/src/emails.ts
@@ -2,11 +2,7 @@ import { DOMAIN } from '../../common/envs/constants'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { Contract } from '../../common/contract'
-import {
- notification_subscription_types,
- PrivateUser,
- User,
-} from '../../common/user'
+import { PrivateUser, User } from '../../common/user'
import {
formatLargeNumber,
formatMoney,
@@ -18,11 +14,12 @@ import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email'
import { getUser } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
-import {
- notification_reason_types,
- getDestinationsForUser,
-} from '../../common/notification'
+import { notification_reason_types } from '../../common/notification'
import { Dictionary } from 'lodash'
+import {
+ getNotificationDestinationsForUser,
+ notification_preference,
+} from '../../common/user-notification-preferences'
export const sendMarketResolutionEmail = async (
reason: notification_reason_types,
@@ -36,8 +33,10 @@ export const sendMarketResolutionEmail = async (
resolutionProbability?: number,
resolutions?: { [outcome: string]: number }
) => {
- const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
- await getDestinationsForUser(privateUser, reason)
+ const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ reason
+ )
if (!privateUser || !privateUser.email || !sendToEmail) return
const user = await getUser(privateUser.id)
@@ -154,7 +153,7 @@ export const sendWelcomeEmail = async (
const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- 'onboarding_flow' as keyof notification_subscription_types
+ 'onboarding_flow' as notification_preference
}`
return await sendTemplateEmail(
@@ -222,7 +221,7 @@ export const sendOneWeekBonusEmail = async (
const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- 'onboarding_flow' as keyof notification_subscription_types
+ 'onboarding_flow' as notification_preference
}`
return await sendTemplateEmail(
privateUser.email,
@@ -255,7 +254,7 @@ export const sendCreatorGuideEmail = async (
const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- 'onboarding_flow' as keyof notification_subscription_types
+ 'onboarding_flow' as notification_preference
}`
return await sendTemplateEmail(
privateUser.email,
@@ -289,7 +288,7 @@ export const sendThankYouEmail = async (
const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- 'thank_you_for_purchases' as keyof notification_subscription_types
+ 'thank_you_for_purchases' as notification_preference
}`
return await sendTemplateEmail(
@@ -312,8 +311,10 @@ export const sendMarketCloseEmail = async (
privateUser: PrivateUser,
contract: Contract
) => {
- const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
- await getDestinationsForUser(privateUser, reason)
+ const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ reason
+ )
if (!privateUser.email || !sendToEmail) return
@@ -350,8 +351,10 @@ export const sendNewCommentEmail = async (
answerText?: string,
answerId?: string
) => {
- const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
- await getDestinationsForUser(privateUser, reason)
+ const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ reason
+ )
if (!privateUser || !privateUser.email || !sendToEmail) return
const { question } = contract
@@ -425,8 +428,10 @@ export const sendNewAnswerEmail = async (
// Don't send the creator's own answers.
if (privateUser.id === creatorId) return
- const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } =
- await getDestinationsForUser(privateUser, reason)
+ const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
+ privateUser,
+ reason
+ )
if (!privateUser.email || !sendToEmail) return
const { question, creatorUsername, slug } = contract
@@ -465,7 +470,7 @@ export const sendInterestingMarketsEmail = async (
return
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
- '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
diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts
index 5fe3fd62..ce75f0fe 100644
--- a/functions/src/on-create-bet.ts
+++ b/functions/src/on-create-bet.ts
@@ -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
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
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(
diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts
index 2972a305..5e2a94c0 100644
--- a/functions/src/on-update-contract.ts
+++ b/functions/src/on-update-contract.ts
@@ -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
diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts
index b867b609..feddd67c 100644
--- a/functions/src/resolve-market.ts
+++ b/functions/src/resolve-market.ts
@@ -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(
+ 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
+ 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()
diff --git a/functions/src/scripts/backfill-contract-followers.ts b/functions/src/scripts/backfill-contract-followers.ts
index 9b936654..9b5834bc 100644
--- a/functions/src/scripts/backfill-contract-followers.ts
+++ b/functions/src/scripts/backfill-contract-followers.ts
@@ -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()
diff --git a/functions/src/scripts/create-new-notification-preferences.ts b/functions/src/scripts/create-new-notification-preferences.ts
index 2796f2f7..4ba2e25e 100644
--- a/functions/src/scripts/create-new-notification-preferences.ts
+++ b/functions/src/scripts/create-new-notification-preferences.ts
@@ -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
diff --git a/functions/src/scripts/create-private-users.ts b/functions/src/scripts/create-private-users.ts
index 21e117cf..762e801a 100644
--- a/functions/src/scripts/create-private-users.ts
+++ b/functions/src/scripts/create-private-users.ts
@@ -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) {
diff --git a/functions/src/scripts/update-bonus-txn-data-fields.ts b/functions/src/scripts/update-bonus-txn-data-fields.ts
new file mode 100644
index 00000000..82955fa0
--- /dev/null
+++ b/functions/src/scripts/update-bonus-txn-data-fields.ts
@@ -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(
+ 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())
diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts
index da7b507f..418282c7 100644
--- a/functions/src/unsubscribe.ts
+++ b/functions/src/unsubscribe.ts
@@ -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 = {
- ...(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(
+ `
+
+
+
+
+ Manifold Markets 7th Day Anniversary Gift!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${email} has been unsubscribed from email notifications related to:
+
+
+
+
+ ${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.
+
+
+
+
+
Click
+ here
+ to manage the rest of your notification settings.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+ )
},
}
diff --git a/functions/src/utils.ts b/functions/src/utils.ts
index a0878e4f..23f7257a 100644
--- a/functions/src/utils.ts
+++ b/functions/src/utils.ts
@@ -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)
diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx
index 0a4ac1e1..4594ea35 100644
--- a/web/components/answers/answer-resolve-panel.tsx
+++ b/web/components/answers/answer-resolve-panel.tsx
@@ -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 (
- Resolve your market
+
+ Resolve your market
+ {isAdmin && !isCreator && (
+
+ ADMIN
+
+ )}
+
)}
- {user?.id === creatorId && !resolution && (
- <>
-
-
- >
- )}
+ {(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) &&
+ !resolution && (
+ <>
+
+
+ >
+ )}
)
}
diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx
index 0bd3702f..c0177fb3 100644
--- a/web/components/bet-button.tsx
+++ b/web/components/bet-button.tsx
@@ -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: {
setOpen(true)}
>
- Predict
+ {PRESENT_BET}
) : (
diff --git a/web/components/bet-inline.tsx b/web/components/bet-inline.tsx
index af75ff7c..a8f4d718 100644
--- a/web/components/bet-inline.tsx
+++ b/web/components/bet-inline.tsx
@@ -79,7 +79,7 @@ export function BetInline(props: {
return (
- Bet
+ Predict
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}
/>
)}
@@ -393,9 +395,7 @@ function ContractSearchControls(props: {
}
return (
-
+
- Your trades
+ Your {PAST_BETS}
)}
diff --git a/web/components/contract-select-modal.tsx b/web/components/contract-select-modal.tsx
index 9e23264a..ea08de01 100644
--- a/web/components/contract-select-modal.tsx
+++ b/web/components/contract-select-modal.tsx
@@ -81,18 +81,22 @@ export function SelectMarketsModal(props: {
)}
-
+
c.id),
highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}
additionalFilter={{}} /* hide pills */
- headerClassName="bg-white"
+ headerClassName="bg-white sticky"
{...contractSearchOptions}
/>
diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx
index dab92a7a..367a5401 100644
--- a/web/components/contract/contract-card.tsx
+++ b/web/components/contract/contract-card.tsx
@@ -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: {
-
+
+
-
)
}
@@ -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 ? (
-
-
-
- {groupToDisplay.name}
-
-
- ) : (
- !groupToDisplay && setOpen(true)}
- >
-
-
- No Group
-
-
- )
+ const isMobile = useIsMobile()
return (
-
-
-
- {disabled ? (
- creatorName
- ) : (
-
- )}
- {!disabled && }
+
+
+
+
+
+
-
- {disabled ? (
- groupInfo
- ) : !groupToDisplay && !user ? (
-
- ) : (
+ {/* GROUPS */}
+ {isMobile && (
+
+
+
+ )}
+
+ )
+}
+
+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 (
+
+
+ {!disabled && (
+
+
+
+ )}
+
+
+ {disabled ? (
+ creatorName
+ ) : (
+
+ )}
+
+
+
+ {!isMobile && (
+
+ )}
+
+
+
+ )
+}
+
+export function CloseOrResolveTime(props: {
+ contract: Contract
+ resolvedDate: any
+ isCreator: boolean
+}) {
+ const { contract, resolvedDate, isCreator } = props
+ const { resolutionTime, closeTime } = contract
+ if (!!closeTime || !!resolvedDate) {
+ return (
+
+ {resolvedDate && resolutionTime ? (
+ <>
+
+
+ resolved
+ {resolvedDate}
+
+
+ >
+ ) : null}
+
+ {!resolvedDate && closeTime && (
- {groupInfo}
- {user && groupToDisplay && (
- closes
}
+ {!dayjs().isBefore(closeTime) && closed
}
+
+
+ )}
+
+ )
+ } 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 (
+ <>
+
+
+ {!disabled && (
+
+ {user && (
+ setOpen(!open)}
>
-
-
+
+
)}
)}
@@ -201,45 +267,7 @@ export function ContractDetails(props: {
-
- {(!!closeTime || !!resolvedDate) && (
-
- {resolvedDate && resolutionTime ? (
- <>
-
-
- {resolvedDate}
-
- >
- ) : null}
-
- {!resolvedDate && closeTime && user && (
- <>
-
-
- >
- )}
-
- )}
- {user && (
- <>
-
-
- {volumeLabel}
-
- {!disabled && (
-
- )}
- >
- )}
-
+ >
)
}
@@ -280,12 +308,12 @@ export function ExtraMobileContractDetails(props: {
!resolvedDate &&
closeTime && (
+ Closes
- Ends
)
)}
@@ -305,6 +333,45 @@ export function ExtraMobileContractDetails(props: {
)
}
+export function GroupDisplay(props: {
+ groupToDisplay?: GroupLink | null
+ isMobile?: boolean
+}) {
+ const { groupToDisplay, isMobile } = props
+ if (groupToDisplay) {
+ return (
+
+
+
+ {groupToDisplay.name}
+
+
+
+ )
+ } else
+ return (
+
+
+ No Group
+
+
+ )
+}
+
function EditableCloseDate(props: {
closeTime: number
contract: Contract
@@ -356,47 +423,59 @@ function EditableCloseDate(props: {
return (
<>
- {isEditingCloseTime ? (
-
- e.stopPropagation()}
- onChange={(e) => setCloseDate(e.target.value)}
- min={Date.now()}
- value={closeDate}
- />
- e.stopPropagation()}
- onChange={(e) => setCloseHoursMinutes(e.target.value)}
- min="00:00"
- value={closeHoursMinutes}
- />
-
+
+
+
+
+ e.stopPropagation()}
+ onChange={(e) => setCloseDate(e.target.value)}
+ min={Date.now()}
+ value={closeDate}
+ />
+ e.stopPropagation()}
+ onChange={(e) => setCloseHoursMinutes(e.target.value)}
+ min="00:00"
+ value={closeHoursMinutes}
+ />
+
+
Done
-
- ) : (
- Date.now() ? 'Trading ends:' : 'Trading ended:'}
- time={closeTime}
+
+
+ Date.now() ? 'Trading ends:' : 'Trading ended:'}
+ time={closeTime}
+ >
+ isCreator && setIsEditingCloseTime(true)}
>
- isCreator && setIsEditingCloseTime(true)}
- >
- {isSameDay ? (
- {fromNow(closeTime)}
- ) : isSameYear ? (
- dayJsCloseTime.format('MMM D')
- ) : (
- dayJsCloseTime.format('MMM D, YYYY')
- )}
-
-
- )}
+ {isSameDay ? (
+ {fromNow(closeTime)}
+ ) : isSameYear ? (
+ dayJsCloseTime.format('MMM D')
+ ) : (
+ dayJsCloseTime.format('MMM D, YYYY')
+ )}
+
+
>
)
}
diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx
index ae586725..5187030d 100644
--- a/web/components/contract/contract-info-dialog.tsx
+++ b/web/components/contract/contract-info-dialog.tsx
@@ -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 (
<>
- setOpen(true)}
>
-
+
-
+
@@ -129,14 +140,9 @@ export function ContractInfoDialog(props: {
{formatMoney(contract.volume)}
- {/*
- Creator earnings
- {formatMoney(contract.collectedFees.creatorFee)}
- */}
-
- Traders
- {bettorsCount}
+ {capitalize(BETTORS)}
+ {uniqueBettorCount ?? '0'}
diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx
index 54b2c79e..fec6744d 100644
--- a/web/components/contract/contract-leaderboard.tsx
+++ b/web/components/contract/contract-leaderboard.tsx
@@ -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 ? (
(
-
+
)
const BetWidget = (props: { contract: CPMMContract }) => {
@@ -73,7 +73,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
const { contract, bets } = props
return (
-
+
@@ -85,7 +85,6 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
-
{tradingAllowed(contract) && (
)}
@@ -113,10 +112,6 @@ const ChoiceOverview = (props: {
-
)
@@ -140,7 +135,6 @@ const PseudoNumericOverview = (props: {
-
{tradingAllowed(contract) && }
diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx
index d63d3963..e1ee141e 100644
--- a/web/components/contract/contract-tabs.tsx
+++ b/web/components/contract/contract-tabs.tsx
@@ -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 && (
{!user ? (
diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx
index c6356fdd..3da9a5d5 100644
--- a/web/components/contract/contracts-grid.tsx
+++ b/web/components/contract/contracts-grid.tsx
@@ -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 (
+
+
+ {user?.id !== contract.creatorId && (
+
+ )}
{
setShareOpen(true)
}}
>
-
-
- Share
-
+
+
+
-
- {showChallenge && (
- setOpenCreateChallengeModal(true),
- 'click challenge button'
- )}
- >
-
-
- Challenge
-
-
-
- )}
-
-
- {user?.id !== contract.creatorId && (
-
- )}
-
+
diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx
index e35e3e7e..01dce32f 100644
--- a/web/components/contract/like-market-button.tsx
+++ b/web/components/contract/like-market-button.tsx
@@ -38,15 +38,16 @@ export function LikeMarketButton(props: {
return (
-
+
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 && (
+ 99
+ ? 'text-[0.4rem] sm:text-[0.5rem]'
+ : 'sm:text-2xs text-[0.5rem]'
+ )}
+ >
+ {totalTipped}
+
+ )}
)
diff --git a/web/components/contract/watch-market-modal.tsx b/web/components/contract/watch-market-modal.tsx
index 2fb9bc00..8f79e1ed 100644
--- a/web/components/contract/watch-market-modal.tsx
+++ b/web/components/contract/watch-market-modal.tsx
@@ -18,21 +18,22 @@ export const WatchMarketModal = (props: {
• What is watching?
- 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
- ️ button on them.
+ ️ button.
• What types of notifications will I receive?
- 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.
diff --git a/web/components/editor.tsx b/web/components/editor.tsx
index 745fc3c5..95f18b3f 100644
--- a/web/components/editor.tsx
+++ b/web/components/editor.tsx
@@ -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
}
diff --git a/web/components/editor/contract-mention-list.tsx b/web/components/editor/contract-mention-list.tsx
new file mode 100644
index 00000000..bda9d2fc
--- /dev/null
+++ b/web/components/editor/contract-mention-list.tsx
@@ -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, 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 (
+
+ {!contracts.length ? (
+
No results...
+ ) : (
+ contracts.map((contract, i) => (
+
submitUser(i)}
+ key={contract.id}
+ >
+
+ {contract.question}
+
+ ))
+ )}
+
+ )
+})
+
+// Just to keep the formatting pretty
+export { M as MentionList }
diff --git a/web/components/editor/contract-mention-suggestion.ts b/web/components/editor/contract-mention-suggestion.ts
new file mode 100644
index 00000000..39d024e7
--- /dev/null
+++ b/web/components/editor/contract-mention-suggestion.ts
@@ -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),
+}
diff --git a/web/components/editor/contract-mention.tsx b/web/components/editor/contract-mention.tsx
new file mode 100644
index 00000000..ddc81bc0
--- /dev/null
+++ b/web/components/editor/contract-mention.tsx
@@ -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 (
+
+ {contract && (
+
+ )}
+
+ )
+}
+
+/**
+ * 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',
+ }),
+})
diff --git a/web/components/editor/mention-suggestion.ts b/web/components/editor/mention-suggestion.ts
index 9f016d47..b4eeeebe 100644
--- a/web/components/editor/mention-suggestion.ts
+++ b/web/components/editor/mention-suggestion.ts
@@ -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
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()
},
}
- },
+ }
}
diff --git a/web/components/editor/tweet-embed.tsx b/web/components/editor/tweet-embed.tsx
index 91b2fa65..fb7d7810 100644
--- a/web/components/editor/tweet-embed.tsx
+++ b/web/components/editor/tweet-embed.tsx
@@ -12,7 +12,7 @@ export default function WrappedTwitterTweetEmbed(props: {
const tweetId = props.node.attrs.tweetId.slice(1)
return (
-
+
)
diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx
index 55b8a958..b8a003fa 100644
--- a/web/components/feed/contract-activity.tsx
+++ b/web/components/feed/contract-activity.tsx
@@ -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 (
-
- {sortedItems.map((item) =>
- item.type === 'bet' ? (
-
- ) : (
-
- )
- )}
-
+ <>
+
+ {pageItems.map((item) =>
+ item.type === 'bet' ? (
+
+ ) : (
+
+ )
+ )}
+
+
+ >
)
}
diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx
index def97801..b2852739 100644
--- a/web/components/feed/feed-bets.tsx
+++ b/web/components/feed/feed-bets.tsx
@@ -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 ? (
) : (
- {self?.id === bet.userId ? 'You' : 'A trader'}
+ {self?.id === bet.userId ? 'You' : `A ${BETTOR}`}
)}{' '}
{bought} {money}
{outOfTotalAmount}
diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx
index f896ddb5..9d2ba85e 100644
--- a/web/components/feed/feed-comments.tsx
+++ b/web/components/feed/feed-comments.tsx
@@ -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 `}
{prob && ' at ' + Math.round(prob * 100) + '%'}
>
diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx
index 8f8faf9b..f4870a4e 100644
--- a/web/components/feed/feed-liquidity.tsx
+++ b/web/components/feed/feed-liquidity.tsx
@@ -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 (
{isSelf ? (
@@ -74,7 +63,7 @@ export function LiquidityStatusText(props: {
{bettor ? (
) : (
- {isSelf ? 'You' : 'A trader'}
+ {isSelf ? 'You' : `A ${BETTOR}`}
)}{' '}
{bought} a subsidy of {money}
diff --git a/web/components/follow-button.tsx b/web/components/follow-button.tsx
index 09495169..6344757d 100644
--- a/web/components/follow-button.tsx
+++ b/web/components/follow-button.tsx
@@ -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 (
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 (
+
+ )
+ }
+ if (
+ !user ||
+ user.id === userId ||
+ isFollowing ||
+ !user ||
+ isFollowing === undefined
+ )
+ return null
+ return (
+ <>
+ follow(user.id, userId), 'follow')}>
+
+
+ >
+ )
+}
diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx
index 1dd261cb..0e65165b 100644
--- a/web/components/follow-market-button.tsx
+++ b/web/components/follow-market-button.tsx
@@ -25,7 +25,7 @@ export const FollowMarketButton = (props: {
return (
{
if (!user) return firebaseLogin()
@@ -56,13 +56,19 @@ export const FollowMarketButton = (props: {
>
{followers?.includes(user?.id ?? 'nope') ? (
-
- Unwatch
+
+ {/* Unwatch */}
) : (
-
- Watch
+
+ {/* Watch */}
)}
Contribute your M$ to make this market more accurate.{' '}
-
+
diff --git a/web/components/nav/group-nav-bar.tsx b/web/components/nav/group-nav-bar.tsx
new file mode 100644
index 00000000..ffa83e54
--- /dev/null
+++ b/web/components/nav/group-nav-bar.tsx
@@ -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 (
+
+ {mobileGroupNavigation.map((item) => (
+
+ ))}
+
+ {mobileGeneralNavigation.map((item) => (
+ {
+ router.push(item.href)
+ }}
+ />
+ ))}
+
+ {user && (
+ {
+ router.push(`/${user.username}?tab=trades`)
+ }}
+ item={userProfileItem(user)}
+ />
+ )}
+
+ )
+}
+
+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 (
+ props.onClick(item.key ?? '#')}>
+
+ {item.icon && }
+ {item.name}
+
+
+ )
+}
diff --git a/web/components/nav/group-sidebar.tsx b/web/components/nav/group-sidebar.tsx
new file mode 100644
index 00000000..3735adc7
--- /dev/null
+++ b/web/components/nav/group-sidebar.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ {groupName}
+
+
+
+
+
+ {user ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Desktop navigation */}
+ {groupNavigation.map((item) => (
+
+ ))}
+ {generalNavigation(user).map((item) => (
+
+ ))}
+
+ {props.joinOrAddQuestionsButton}
+
+ )
+}
diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx
index 242d6ff5..778cdd1a 100644
--- a/web/components/nav/nav-bar.tsx
+++ b/web/components/nav/nav-bar.tsx
@@ -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: () => (
+
+ ),
+})
+
// From https://codepen.io/chris__sev/pen/QWGvYbL
export function BottomNavBar() {
const [sidebarOpen, setSidebarOpen] = useState(false)
@@ -61,20 +79,7 @@ export function BottomNavBar() {
(
-
- ),
- }}
+ item={userProfileItem(user)}
/>
)}
+
+
(
+ return buildArray(
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 (
-
-
- {item.icon && (
-
- )}
- {item.name}
-
-
+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 = (
+
+ {item.icon && (
+
+ )}
+ {item.name}
+
)
+
+ if (item.href) {
+ return (
+
+ {sidebarItem}
+
+ )
+ } else {
+ return onClick ? (
+ onClick(item.key ?? '#')}>{sidebarItem}
+ ) : (
+ <> >
+ )
+ }
}
function SidebarButton(props: {
diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx
index d18896bd..7c1f3546 100644
--- a/web/components/notification-settings.tsx
+++ b/web/components/notification-settings.tsx
@@ -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 = [
+ const emailsEnabled: Array = [
'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 = [
+ const browserDisabled: Array = [
'trending_markets',
'profit_loss_updates',
'onboarding_flow',
@@ -83,91 +83,82 @@ export function NotificationSettings(props: {
type SectionData = {
label: string
- subscriptionTypeToDescription: {
- [key in keyof Partial]: string
- }
+ subscriptionTypes: Partial[]
}
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: {
)}
- {Object.entries(subscriptionTypeToDescription).map(([key, value]) => (
+ {subscriptionTypes.map((subType) => (
))}
diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx
index dce36ab9..0220f7a7 100644
--- a/web/components/numeric-resolution-panel.tsx
+++ b/web/components/numeric-resolution-panel.tsx
@@ -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 (
-
- Resolve market
+
+ {isAdmin && !isCreator && (
+
+ ADMIN
+
+ )}
+ Resolve market
- Outcome
+ Outcome
@@ -99,9 +112,12 @@ export function NumericResolutionPanel(props: {
{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}.>
)}
diff --git a/web/components/profile/loans-modal.tsx b/web/components/profile/loans-modal.tsx
index 24b23e5b..5dcb8b6b 100644
--- a/web/components/profile/loans-modal.tsx
+++ b/web/components/profile/loans-modal.tsx
@@ -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: {
🏦
- Daily loans on your trades
+ Daily loans on your {PAST_BETS}
• What are daily loans?
diff --git a/web/components/profile/twitch-panel.tsx b/web/components/profile/twitch-panel.tsx
deleted file mode 100644
index b284b242..00000000
--- a/web/components/profile/twitch-panel.tsx
+++ /dev/null
@@ -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
- color?: ColorType
-}) {
- const { children, onClick, color } = props
- return (
-
- {children}
-
- )
-}
-
-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 =
-
- 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 (
- <>
-
- Twitch
-
- {!twitchName ? (
-
-
- Link your Twitch account
-
- {twitchLoading && }
-
- ) : (
-
- Linked Twitch account {' '}
- {twitchName}
-
- )}
-
-
- {twitchToken && (
-
-
-
-
- Copy overlay link
-
-
- Copy dock link
-
- {twitchBotConnected ? (
-
- Remove bot from your channel
-
- ) : (
-
- Add bot to your channel
-
- )}
-
-
-
- )}
- >
- )
-}
diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx
index 5a7b993e..7ef6e4f3 100644
--- a/web/components/resolution-panel.tsx
+++ b/web/components/resolution-panel.tsx
@@ -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 (
-
+
+ {isAdmin && !isCreator && (
+
+ ADMIN
+
+ )}
Resolve market
Outcome
@@ -83,23 +91,28 @@ export function ResolutionPanel(props: {
{outcome === 'YES' ? (
<>
- Winnings will be paid out to traders who bought YES.
+ Winnings will be paid out to {BETTORS} who bought YES.
{/*
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.
{/*
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' ? (
-
Traders will be paid out at the probability you specify:
+
+ {PAST_BETS} will be paid out at the probability you specify:
+
) : (
- <>Resolving this market will immediately pay out traders.>
+ <>Resolving this market will immediately pay out {BETTORS}.>
)}
diff --git a/web/components/user-link.tsx b/web/components/user-link.tsx
index e1b675a0..4b05ccd0 100644
--- a/web/components/user-link.tsx
+++ b/web/components/user-link.tsx
@@ -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 (
{shortName}
diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx
index 5485267c..2b24fa60 100644
--- a/web/components/user-page.tsx
+++ b/web/components/user-page.tsx
@@ -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 }) {
Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend!
{' '}
- You have
+ You've gotten{' '}
+
diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts
index c99940d5..58817592 100644
--- a/web/hooks/use-contracts.ts
+++ b/web/hooks/use-contracts.ts
@@ -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],
diff --git a/web/hooks/use-is-mobile.ts b/web/hooks/use-is-mobile.ts
new file mode 100644
index 00000000..5754a589
--- /dev/null
+++ b/web/hooks/use-is-mobile.ts
@@ -0,0 +1,6 @@
+import { useWindowSize } from 'web/hooks/use-window-size'
+
+export function useIsMobile() {
+ const { width } = useWindowSize()
+ return (width ?? 0) < 600
+}
diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts
index d8ce025e..1de25bab 100644
--- a/web/hooks/use-notifications.ts
+++ b/web/hooks/use-notifications.ts
@@ -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',
}
diff --git a/web/lib/icons/corner-down-right-icon.tsx b/web/lib/icons/corner-down-right-icon.tsx
new file mode 100644
index 00000000..37d61afa
--- /dev/null
+++ b/web/lib/icons/corner-down-right-icon.tsx
@@ -0,0 +1,19 @@
+export default function CornerDownRightIcon(props: { className?: string }) {
+ return (
+
+
+
+
+ )
+}
diff --git a/web/lib/twitch/link-twitch-account.ts b/web/lib/twitch/link-twitch-account.ts
index 36fb12b5..f36a03b3 100644
--- a/web/lib/twitch/link-twitch-account.ts
+++ b/web/lib/twitch/link-twitch-account.ts
@@ -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}`
}
diff --git a/web/package.json b/web/package.json
index 114ded1e..ba25a6e1 100644
--- a/web/package.json
+++ b/web/package.json
@@ -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",
diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx
index de0c7807..a0b2ed50 100644
--- a/web/pages/[username]/[contractSlug].tsx
+++ b/web/pages/[username]/[contractSlug].tsx
@@ -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 ? (
-
+
) : (
-
+
))}
) : 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(
)}
-
{outcomeType === 'NUMERIC' && (
diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx
index 4691030c..4d6ada1d 100644
--- a/web/pages/contract-search-firestore.tsx
+++ b/web/pages/contract-search-firestore.tsx
@@ -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: {
>
Trending
Newest
- Most traded
+ Most ${PAST_BETS}
24h volume
Closing soon
diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx
index c5fba0c8..62dd1ae1 100644
--- a/web/pages/embed/[username]/[contractSlug].tsx
+++ b/web/pages/embed/[username]/[contractSlug].tsx
@@ -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 (
-
-
+
+
{question}
+ {isBinary && (
+
+ )}
-
+ {isPseudoNumeric && (
+
+ )}
-
-
+ {outcomeType === 'FREE_RESPONSE' && (
+
+ )}
- {(isBinary || isPseudoNumeric) &&
- tradingAllowed(contract) &&
- !betPanelOpen && (
- setBetPanelOpen(true)}>
- Bet
-
- )}
+ {outcomeType === 'NUMERIC' && (
+
+ )}
+
+
+
+
- {isBinary && (
-
+ {(isBinary || isPseudoNumeric) &&
+ tradingAllowed(contract) &&
+ !betPanelOpen && (
+ setBetPanelOpen(true)}>
+ Predict
+
)}
+
- {isPseudoNumeric && (
-
- )}
-
- {outcomeType === 'FREE_RESPONSE' && (
-
- )}
-
- {outcomeType === 'NUMERIC' && (
-
- )}
-
-
-
-
+
{(isBinary || isPseudoNumeric) && betPanelOpen && (
diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx
index f1521b42..1edcc638 100644
--- a/web/pages/group/[...slugs]/index.tsx
+++ b/web/pages/group/[...slugs]/index.tsx
@@ -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 = (
@@ -169,7 +173,7 @@ export default function GroupPage(props: {
)
- const aboutTab = (
+ const aboutPage = (
{(group.aboutPostId != null || isCreator || isAdmin) && (
)
- const questionsTab = (
-
+ const questionsPage = (
+ <>
+ {/* align the divs to the right */}
+
+
+ >
)
- 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 = (
+
+ )
return (
-
-
-
-
-
-
- {group.name}
-
-
-
-
-
-
-
-
-
-
- 0 ? tabIndex : 0}
- tabs={tabs}
- />
-
+ <>
+
+
+
+
+
+
+
+
+ {pageContent}
+
+
+
+
+ >
+ )
+}
+
+export function TopGroupNavBar(props: { group: Group }) {
+ return (
+
)
}
@@ -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 ? (
-
+
) : group.anyoneCanJoin ? (
@@ -410,9 +460,9 @@ function AddContractButton(props: { group: Group; user: User }) {
return (
<>
-
+
setOpen(true)}
@@ -467,7 +517,9 @@ function JoinGroupButton(props: {
Follow
diff --git a/web/pages/home.tsx b/web/pages/home.tsx
index 972aa639..50e2c35f 100644
--- a/web/pages/home.tsx
+++ b/web/pages/home.tsx
@@ -26,6 +26,7 @@ const Home = () => {
user={user}
persistPrefix="home-search"
useQueryUrlParam={true}
+ headerClassName="sticky"
/>
diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx
index 008f5df1..2f5c0bf9 100644
--- a/web/pages/notifications.tsx
+++ b/web/pages/notifications.tsx
@@ -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: {
) : (
+ )
+ } else if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
+ return (
+
+ )
+ }
+ // TODO Add new notification components here
if (justSummary) {
return (
-
-
-
-
-
-
- {sourceType &&
- reason &&
- getReasonForShowingNotification(notification, true)}
-
-
-
-
-
-
-
-
+
+
+
)
}
+ return (
+
+
+
+
+
+ )
+}
+
+function NotificationSummaryFrame(props: {
+ notification: Notification
+ subtitle: string
+ children: React.ReactNode
+}) {
+ const { notification, subtitle, children } = props
+ const { sourceUserName, sourceUserUsername } = notification
+ return (
+
+
+
+
+
+
{subtitle}
+
{children}
+
+
+
+
+ )
+}
+
+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 (
- {!questionNeedsResolution && (
-
- )}
- {getReasonForShowingNotification(
- notification,
- isChildOfGroup ?? false
- )}
+
+ {subtitle}
{isChildOfGroup ? (
) : (
@@ -822,9 +888,7 @@ function NotificationItem(props: {
)}
-
-
-
+
{children}
@@ -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 ? (
+
+ of your {limitOrderTotal ? formatMoney(limitOrderTotal) : ''}
+
+ {creatorOutcome}
+
+ limit order at {Math.round(probability * 100)}% was filled{' '}
+ {limitOrderRemaining
+ ? `(${formatMoney(limitOrderRemaining)} remaining)`
+ : ''}
+
+ ) : (
+ of your limit order was filled
+ )
+
+ if (justSummary) {
+ return (
+
+
+ {amount}
+ {description}
+
+
+ )
+ }
+
+ return (
+
+
+
+ {amount}
+ {description}
+
+
+
+ )
+}
+
+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
+ if (sourceText === 'YES' || sourceText == 'NO') {
+ return
+ }
+ if (sourceText.includes('%'))
+ return
+ if (sourceText === 'CANCEL') return
+ if (sourceText === 'MKT' || sourceText === 'PROB') return
+
+ // Numeric market
+ if (parseFloat(sourceText))
+ return
+
+ // Free response market
+ return (
+
+
+
+ )
+ }
+
+ const description =
+ userInvestment && userPayout !== undefined ? (
+
+ {resolutionDescription()}
+ Invested:
+ {formatMoney(userInvestment)}
+ Payout:
+ 0 ? 'text-primary' : 'text-red-500',
+ 'truncate'
+ )}
+ >
+ {formatMoney(userPayout)}
+ {` (${userPayout > 0 ? '+' : ''}${Math.round(
+ ((userPayout - userInvestment) / userInvestment) * 100
+ )}%)`}
+
+
+ ) : (
+ {resolutionDescription()}
+ )
+
+ if (justSummary) {
+ return (
+
+ {description}
+
+ )
+ }
+
+ return (
+
+
+ {description}
+
+
+ )
+}
+
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
// Resolved contracts
- if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
- {
- if (sourceText === 'YES' || sourceText == 'NO') {
- return
- }
- if (sourceText.includes('%'))
- return (
-
- )
- if (sourceText === 'CANCEL') return
- if (sourceText === 'MKT' || sourceText === 'PROB') return
- // Numeric market
- if (parseFloat(sourceText))
- return
-
- // Free response market
- return (
-
-
-
- )
- }
- }
// Close date will be a number - it looks better without it
if (sourceUpdateType === 'closed') {
return
@@ -1002,15 +1185,6 @@ function NotificationTextLabel(props: {
return (
{formatMoney(parseInt(sourceText))}
)
- } else if (sourceType === 'bet' && sourceText) {
- return (
- <>
-
- {formatMoney(parseInt(sourceText))}
- {' '}
- of your limit order was filled
- >
- )
} 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
diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx
index 6b70b5d2..2c095db6 100644
--- a/web/pages/profile.tsx
+++ b/web/pages/profile.tsx
@@ -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
/>
- ,
+ }}
+ submitBtn={{
+ label: 'Update key',
+ className: 'btn-primary',
+ }}
+ onSubmitWithSuccess={async () => {
+ updateApiKey()
+ return true
+ }}
>
-
-
+
+
+
+ Updating your API key will break any existing applications
+ connected to your account,
including the Twitch bot .
+ You will need to go to the{' '}
+
+
+ Twitch page
+
+ {' '}
+ to relink your account.
+
+
+
-
-
diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx
index bca0525a..08fb5498 100644
--- a/web/pages/stats.tsx
+++ b/web/pages/stats.tsx
@@ -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
(undefined)
@@ -156,7 +158,7 @@ export function CustomAnalytics(props: {
defaultIndex={0}
tabs={[
{
- title: 'Trades',
+ title: capitalize(PAST_BETS),
content: (
{
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 ? (
+
+ ) : (
+
+ {needsRelink ? 'API key updated: relink Twitch' : 'Start playing'}
+
+ )
+}
+
+function TwitchPlaysManifoldMarkets(props: {
+ user?: User | null
+ privateUser?: PrivateUser | null
+}) {
+ const { user, privateUser } = props
+
+ const twitchInfo = privateUser?.twitchInfo
+ const twitchUser = twitchInfo?.twitchName
+
+ return (
+
+
+
+
+
+
+
+ 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.
+
+
+ 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.
+
+ Start playing now by logging in with Google and typing commands in chat!
+ {twitchUser && !twitchInfo.needsRelinking ? (
+
+ Account connected: {twitchUser}
+
+ ) : (
+
+ )}
+
+ 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{' '}
+
+
donated to a charity
+ {' '}
+ of their choice at no cost!
+
+
+
+ )
+}
+
+function Subtitle(props: { text: string }) {
+ const { text } = props
+ return {text}
+}
+
+function Command(props: { command: string; desc: string }) {
+ const { command, desc } = props
+ return (
+
+
{'!' + command}
+ {' - '}
+
{desc}
+
+ )
+}
+
+function TwitchChatCommands() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function BotSetupStep(props: {
+ stepNum: number
+ buttonName?: string
+ buttonOnClick?: MouseEventHandler
+ overrideButton?: ReactNode
+ children: ReactNode
+}) {
+ const { stepNum, buttonName, buttonOnClick, overrideButton, children } = props
+ return (
+
+ {(overrideButton || buttonName) && (
+ <>
+ {overrideButton ?? (
+
+ {buttonName}
+
+ )}
+
+ >
+ )}
+
+
Step {stepNum}.
+ {children}
+
+
+ )
+}
+
+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 ? (
+
+ {loading ? (
+
+ ) : (
+ 'Remove bot from channel'
+ )}
+
+ ) : (
+
+ {loading ? (
+
+ ) : (
+ 'Add bot to your channel'
+ )}
+
+ )}
+ >
+ )
+}
+
+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: ,
+ }
+ 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 (
+ <>
+
+
+
+ To add the bot to your stream make sure you have logged in then follow
+ the steps below.
+ {!twitchLinked && (
+
+ )}
+
+
+ }
+ >
+ Use the button above to add the bot to your channel. Then mod it by
+ typing in your Twitch chat: /mod ManifoldBot
+
+ If the bot is not modded it will not be able to respond to commands
+ properly.
+
+
+ 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.
+
+
+ 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.
+
+
+
+ >
+ )
+}
+
+export default function TwitchLandingPage() {
+ useSaveReferral()
+ useTracking('view twitch landing page')
+
+ const user = useUser()
+ const privateUser = usePrivateUser()
return (
@@ -62,58 +408,11 @@ export default function TwitchLandingPage() {
-
-
-
-
-
-
-
-
-
-
-
- Bet
- {' '}
- on your favorite streams
-
-
-
-
- Get more out of Twitch with play-money betting markets.{' '}
- {!twitchUser &&
- 'Click the button below to link your Twitch account.'}
-
-
-
-
-
- {twitchUser ? (
-
-
-
- Twitch account linked
-
-
- {twitchUser}
-
-
-
- ) : isLoading ? (
-
- ) : (
-
- Get started
-
- )}
-
-
+
+
+
+
)
diff --git a/web/public/twitch-glitch.svg b/web/public/twitch-glitch.svg
new file mode 100644
index 00000000..3120fea7
--- /dev/null
+++ b/web/public/twitch-glitch.svg
@@ -0,0 +1,21 @@
+
+
+
+
+Asset 2
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/tailwind.config.js b/web/tailwind.config.js
index eb411216..7bea3ec2 100644
--- a/web/tailwind.config.js
+++ b/web/tailwind.config.js
@@ -26,6 +26,8 @@ module.exports = {
'greyscale-5': '#9191A7',
'greyscale-6': '#66667C',
'greyscale-7': '#111140',
+ 'highlight-blue': '#5BCEFF',
+ 'hover-blue': '#90DEFF',
},
typography: {
quoteless: {