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

- Hi {{name}},

-
-
-
-

Thanks for - using Manifold Markets. Running low - on mana (M$)? Click the link below to receive a one time gift of M$500!

-
-
-

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

-

 

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

This e-mail has been sent to {{name}}, - click here to manage your notifications. -

-
-
-
-
-
-
-
- -
- - - - - - \ 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/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! + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+ + banner logo + +
+
+

+ Hello!

+
+
+
+

+ + ${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: { ) : ( 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} - - - ) : ( - - ) + 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 && ( -
} + {!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 && ( + + + )} )} @@ -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} - /> - - - ) : ( - 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 ( <> - + - + <Title className="!mt-0 !mb-0" text="This Market" /> <table className="table-compact table-zebra table w-full text-gray-500"> <tbody> @@ -129,14 +140,9 @@ export function ContractInfoDialog(props: { <td>{formatMoney(contract.volume)}</td> </tr> - {/* <tr> - <td>Creator earnings</td> - <td>{formatMoney(contract.collectedFees.creatorFee)}</td> - </tr> */} - <tr> - <td>Traders</td> - <td>{bettorsCount}</td> + <td>{capitalize(BETTORS)}</td> + <td>{uniqueBettorCount ?? '0'}</td> </tr> <tr> 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 ? ( <Leaderboard - title="🏅 Top traders" + title={`🏅 Top ${BETTORS}`} users={users || []} columns={[ { diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 1bfe84de..bfb4829f 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -25,11 +25,11 @@ import { NumericContract, PseudoNumericContract, } from 'common/contract' -import { ContractDetails, ExtraMobileContractDetails } from './contract-details' +import { ContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' const OverviewQuestion = (props: { text: string }) => ( - <Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} /> + <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} /> ) const BetWidget = (props: { contract: CPMMContract }) => { @@ -73,7 +73,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { const { contract, bets } = props return ( <Col className="gap-1 md:gap-2"> - <Col className="gap-3 px-2 sm:gap-4"> + <Col className="gap-1 px-2"> <ContractDetails contract={contract} /> <Row className="justify-between gap-4"> <OverviewQuestion text={contract.question} /> @@ -85,7 +85,6 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { </Row> <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> - <ExtraMobileContractDetails contract={contract} /> {tradingAllowed(contract) && ( <BetWidget contract={contract as CPMMBinaryContract} /> )} @@ -113,10 +112,6 @@ const ChoiceOverview = (props: { </Col> <Col className={'mb-1 gap-y-2'}> <AnswersGraph contract={contract} bets={[...bets].reverse()} /> - <ExtraMobileContractDetails - contract={contract} - forceShowVolume={true} - /> </Col> </Col> ) @@ -140,7 +135,6 @@ const PseudoNumericOverview = (props: { </Row> <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> - <ExtraMobileContractDetails contract={contract} /> {tradingAllowed(contract) && <BetWidget contract={contract} />} </Row> </Col> 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 && ( <ContractBetsActivity contract={contract} bets={visibleBets} @@ -114,13 +127,18 @@ export function ContractTabs(props: { badge: `${comments.length}`, }, { - title: 'Trades', + title: capitalize(PAST_BETS), content: betActivity, - badge: `${visibleBets.length}`, + badge: `${visibleBets.length + visibleLps.length}`, }, ...(!user || !userBets?.length ? [] - : [{ title: 'Your trades', content: yourTrades }]), + : [ + { + title: isMobile ? `You` : `Your ${PAST_BETS}`, + content: yourTrades, + }, + ]), ]} /> {!user ? ( 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 ( <ContractSearch + headerClassName="sticky" user={user} defaultSort="newest" defaultFilter="all" diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index 5d5ee4d8..af5db9c3 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -11,38 +11,29 @@ import { FollowMarketButton } from 'web/components/follow-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button' import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' import { Col } from 'web/components/layout/col' -import { withTracking } from 'web/lib/service/analytics' -import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' -import { CHALLENGES_ENABLED } from 'common/challenge' -import ChallengeIcon from 'web/lib/icons/challenge-icon' export function ExtraContractActionsRow(props: { contract: Contract }) { const { contract } = props - const { outcomeType, resolution } = contract const user = useUser() const [isShareOpen, setShareOpen] = useState(false) - const [openCreateChallengeModal, setOpenCreateChallengeModal] = - useState(false) - const showChallenge = - user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED return ( - <Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}> + <Row> + <FollowMarketButton contract={contract} user={user} /> + {user?.id !== contract.creatorId && ( + <LikeMarketButton contract={contract} user={user} /> + )} <Button - size="lg" + size="sm" color="gray-white" className={'flex'} onClick={() => { setShareOpen(true) }} > - <Col className={'items-center sm:flex-row'}> - <ShareIcon - className={clsx('h-[24px] w-5 sm:mr-2')} - aria-hidden="true" - /> - <span>Share</span> - </Col> + <Row> + <ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" /> + </Row> <ShareModal isOpen={isShareOpen} setOpen={setShareOpen} @@ -50,35 +41,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { user={user} /> </Button> - - {showChallenge && ( - <Button - size="lg" - color="gray-white" - className="max-w-xs self-center" - onClick={withTracking( - () => setOpenCreateChallengeModal(true), - 'click challenge button' - )} - > - <Col className="items-center sm:flex-row"> - <ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" /> - <span>Challenge</span> - </Col> - <CreateChallengeModal - isOpen={openCreateChallengeModal} - setOpen={setOpenCreateChallengeModal} - user={user} - contract={contract} - /> - </Button> - )} - - <FollowMarketButton contract={contract} user={user} /> - {user?.id !== contract.creatorId && ( - <LikeMarketButton contract={contract} user={user} /> - )} - <Col className={'justify-center md:hidden'}> + <Col className={'justify-center'}> <ContractInfoDialog contract={contract} /> </Col> </Row> 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 ( <Button - size={'lg'} + size={'sm'} className={'max-w-xs self-center'} color={'gray-white'} onClick={onLike} > - <Col className={'items-center sm:flex-row'}> + <Col className={'relative items-center sm:flex-row'}> <HeartIcon className={clsx( - 'h-[24px] w-5 sm:mr-2', + 'h-5 w-5 sm:h-6 sm:w-6', + totalTipped > 0 ? 'mr-2' : '', user && (userLikedContractIds?.includes(contract.id) || (!likes && contract.likedByUserIds?.includes(user.id))) @@ -54,7 +55,18 @@ export function LikeMarketButton(props: { : '' )} /> - Tip {totalTipped > 0 ? `(${formatMoney(totalTipped)})` : ''} + {totalTipped > 0 && ( + <div + className={clsx( + 'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1', + totalTipped > 99 + ? 'text-[0.4rem] sm:text-[0.5rem]' + : 'sm:text-2xs text-[0.5rem]' + )} + > + {totalTipped} + </div> + )} </Col> </Button> ) 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: { <Col className={'gap-2'}> <span className={'text-indigo-700'}>• What is watching?</span> <span className={'ml-2'}> - You'll receive notifications on markets by betting, commenting, or - clicking the + Watching a market means you'll receive notifications from activity + on it. You automatically start watching a market if you comment on + it, bet on it, or click the <EyeIcon className={clsx('ml-1 inline h-6 w-6 align-top')} aria-hidden="true" /> - ️ button on them. + ️ button. </span> <span className={'text-indigo-700'}> • What types of notifications will I receive? </span> <span className={'ml-2'}> - You'll receive notifications for new comments, answers, and updates - to the question. See the notifications settings pages to customize - which types of notifications you receive on watched markets. + New comments, answers, and updates to the question. See the + notifications settings pages to customize which types of + notifications you receive on watched markets. </span> </Col> </Col> 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 <EditorContent className={className} editor={editor} /> } 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<Contract>, ref) => { + const { items: contracts, command } = props + + const [selectedIndex, setSelectedIndex] = useState(0) + useEffect(() => setSelectedIndex(0), [contracts]) + + const submitUser = (index: number) => { + const contract = contracts[index] + if (contract) + command({ id: contract.id, label: contractPath(contract) } as any) + } + + const onUp = () => + setSelectedIndex((i) => (i + contracts.length - 1) % contracts.length) + const onDown = () => setSelectedIndex((i) => (i + 1) % contracts.length) + const onEnter = () => submitUser(selectedIndex) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: any) => { + if (event.key === 'ArrowUp') { + onUp() + return true + } + if (event.key === 'ArrowDown') { + onDown() + return true + } + if (event.key === 'Enter') { + onEnter() + return true + } + return false + }, + })) + + return ( + <div className="w-42 absolute z-10 overflow-x-hidden rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> + {!contracts.length ? ( + <span className="m-1 whitespace-nowrap">No results...</span> + ) : ( + contracts.map((contract, i) => ( + <button + className={clsx( + 'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4 hover:bg-indigo-200', + selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900' + )} + onClick={() => submitUser(i)} + key={contract.id} + > + <Avatar avatarUrl={contract.creatorAvatarUrl} size="xs" /> + {contract.question} + </button> + )) + )} + </div> + ) +}) + +// Just to keep the formatting pretty +export { M as MentionList } 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 ( + <NodeViewWrapper className={clsx(name, 'not-prose')}> + {contract && ( + <ContractCard + contract={contract} + className="my-2 w-full border border-gray-100" + /> + )} + </NodeViewWrapper> + ) +} + +/** + * Mention extension that renders React. See: + * https://tiptap.dev/guide/custom-extensions#extend-existing-extensions + * https://tiptap.dev/guide/node-views/react#render-a-react-component + */ +export const DisplayContractMention = Mention.extend({ + name: 'contract-mention', + parseHTML: () => [{ tag: name }], + renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)], + addNodeView: () => + ReactNodeViewRenderer(ContractMentionComponent, { + // On desktop, render cards below half-width so you can stack two + className: 'inline-block sm:w-[calc(50%-1rem)] sm:mr-1', + }), +}) 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<typeof tippy> return { onStart: (props) => { - component = new ReactRenderer(MentionList, { + component = new ReactRenderer(mentionList, { props, editor: props.editor, }) @@ -59,10 +64,16 @@ export const mentionSuggestion: Suggestion = { }) }, onKeyDown(props) { - if (props.event.key === 'Escape') { - popup?.[0].hide() - return true - } + if (props.event.key) + if ( + props.event.key === 'Escape' || + // Also break out of the mention if the tooltip isn't visible + (props.event.key === 'Enter' && !popup?.[0].state.isShown) + ) { + popup?.[0].destroy() + component?.destroy() + return false + } return (component?.ref as any)?.onKeyDown(props) }, onExit() { @@ -70,5 +81,5 @@ export const mentionSuggestion: Suggestion = { component?.destroy() }, } - }, + } } 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 ( - <NodeViewWrapper className="tiptap-tweet"> + <NodeViewWrapper className="tiptap-tweet [&_.twitter-tweet]:mx-auto"> <TwitterTweetEmbed tweetId={tweetId} /> </NodeViewWrapper> ) 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 ( - <Col className="gap-4"> - {sortedItems.map((item) => - item.type === 'bet' ? ( - <FeedBet key={item.id} contract={contract} bet={item.bet} /> - ) : ( - <FeedLiquidity key={item.id} liquidity={item.lp} /> - ) - )} - </Col> + <> + <Col className="mb-4 gap-4"> + {pageItems.map((item) => + item.type === 'bet' ? ( + <FeedBet key={item.id} contract={contract} bet={item.bet} /> + ) : ( + <FeedLiquidity key={item.id} liquidity={item.lp} /> + ) + )} + </Col> + <Pagination + page={page} + itemsPerPage={50} + totalItems={items.length} + setPage={setPage} + scrollToTop + nextTitle={'Older'} + prevTitle={'Newer'} + /> + </> ) } 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 ? ( <UserLink name={bet.userName} username={bet.userUsername} /> ) : ( - <span>{self?.id === bet.userId ? 'You' : 'A trader'}</span> + <span>{self?.id === bet.userId ? 'You' : `A ${BETTOR}`}</span> )}{' '} {bought} {money} {outOfTotalAmount} 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 `} <OutcomeLabel outcome={outcome} contract={contract} truncate="short" /> {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 ( <Row className="items-center gap-2 pt-3"> {isSelf ? ( @@ -74,7 +63,7 @@ export function LiquidityStatusText(props: { {bettor ? ( <UserLink name={bettor.name} username={bettor.username} /> ) : ( - <span>{isSelf ? 'You' : 'A trader'}</span> + <span>{isSelf ? 'You' : `A ${BETTOR}`}</span> )}{' '} {bought} a subsidy of {money} <RelativeTimestamp time={createdTime} /> 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 ( <FollowButton isFollowing={isFollowing} - onFollow={() => follow(currentUser.id, userId)} - onUnfollow={() => unfollow(currentUser.id, userId)} + onFollow={() => follow(user.id, userId)} + onUnfollow={() => unfollow(user.id, userId)} small={small} /> ) } + +export function MiniUserFollowButton(props: { userId: string }) { + const { userId } = props + const user = useUser() + const following = useFollows(user?.id) + const isFollowing = following?.includes(userId) + const isFirstRender = useRef(true) + const [justFollowed, setJustFollowed] = useState(false) + + useEffect(() => { + if (isFirstRender.current) { + if (isFollowing != undefined) { + isFirstRender.current = false + } + return + } + if (isFollowing) { + setJustFollowed(true) + setTimeout(() => { + setJustFollowed(false) + }, 1000) + } + }, [isFollowing]) + + if (justFollowed) { + return ( + <CheckCircleIcon + className={clsx( + 'text-highlight-blue ml-3 mt-2 h-5 w-5 rounded-full bg-white sm:mr-2' + )} + aria-hidden="true" + /> + ) + } + if ( + !user || + user.id === userId || + isFollowing || + !user || + isFollowing === undefined + ) + return null + return ( + <> + <button onClick={withTracking(() => follow(user.id, userId), 'follow')}> + <PlusCircleIcon + className={clsx( + 'text-highlight-blue hover:text-hover-blue mt-2 ml-3 h-5 w-5 rounded-full bg-white sm:mr-2' + )} + aria-hidden="true" + /> + </button> + </> + ) +} 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 ( <Button - size={'lg'} + size={'sm'} color={'gray-white'} onClick={async () => { if (!user) return firebaseLogin() @@ -56,13 +56,19 @@ export const FollowMarketButton = (props: { > {followers?.includes(user?.id ?? 'nope') ? ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" /> - Unwatch + <EyeOffIcon + className={clsx('h-5 w-5 sm:h-6 sm:w-6')} + aria-hidden="true" + /> + {/* Unwatch */} </Col> ) : ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" /> - Watch + <EyeIcon + className={clsx('h-5 w-5 sm:h-6 sm:w-6')} + aria-hidden="true" + /> + {/* Watch */} </Col> )} <WatchMarketModal diff --git a/web/components/liquidity-panel.tsx b/web/components/liquidity-panel.tsx index 0474abf7..58f57a8a 100644 --- a/web/components/liquidity-panel.tsx +++ b/web/components/liquidity-panel.tsx @@ -13,6 +13,7 @@ import { NoLabel, YesLabel } from './outcome-label' import { Col } from './layout/col' import { track } from 'web/lib/service/analytics' import { InfoTooltip } from './info-tooltip' +import { BETTORS, PRESENT_BET } from 'common/user' export function LiquidityPanel(props: { contract: CPMMContract }) { const { contract } = props @@ -104,7 +105,9 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) { <> <div className="mb-4 text-gray-500"> Contribute your M$ to make this market more accurate.{' '} - <InfoTooltip text="More liquidity stabilizes the market, encouraging traders to bet. You can withdraw your subsidy at any time." /> + <InfoTooltip + text={`More liquidity stabilizes the market, encouraging ${BETTORS} to ${PRESENT_BET}. You can withdraw your subsidy at any time.`} + /> </div> <Row> 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 ( + <nav className="fixed inset-x-0 bottom-0 z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden"> + {mobileGroupNavigation.map((item) => ( + <NavBarItem + key={item.name} + item={item} + currentPage={currentPage} + onClick={props.onClick} + /> + ))} + + {mobileGeneralNavigation.map((item) => ( + <NavBarItem + key={item.name} + item={item} + currentPage={currentPage} + onClick={() => { + router.push(item.href) + }} + /> + ))} + + {user && ( + <NavBarItem + key={'profile'} + currentPage={currentPage} + onClick={() => { + router.push(`/${user.username}?tab=trades`) + }} + item={userProfileItem(user)} + /> + )} + </nav> + ) +} + +function NavBarItem(props: { + item: Item + currentPage: string + onClick: (key: string) => void +}) { + const { item, currentPage } = props + const track = trackCallback( + `group navbar: ${item.trackingEventName ?? item.name}` + ) + + return ( + <button onClick={() => props.onClick(item.key ?? '#')}> + <a + className={clsx( + 'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700', + currentPage === item.key && 'bg-gray-200 text-indigo-700' + )} + onClick={track} + > + {item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />} + {item.name} + </a> + </button> + ) +} 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 ( + <nav + aria-label="Group Sidebar" + className={clsx('flex max-h-[100vh] flex-col', className)} + > + <ManifoldLogo className="pt-6" twoLine /> + <Row className="pl-2"> + <Col className="flex justify-center"> + <CornerDownRightIcon className=" h-6 w-6 text-indigo-700" /> + </Col> + <Col> + <div className={' text-2xl text-indigo-700 sm:mb-1 sm:mt-3'}> + {groupName} + </div> + </Col> + </Row> + + <div className=" min-h-0 shrink flex-col items-stretch gap-1 pt-6 lg:flex "> + {user ? ( + <ProfileSummary user={user} /> + ) : ( + <SignInButton className="mb-4" /> + )} + </div> + + {/* Desktop navigation */} + {groupNavigation.map((item) => ( + <SidebarItem + key={item.key} + item={item} + currentPage={currentKey} + onClick={props.onClick} + /> + ))} + {generalNavigation(user).map((item) => ( + <SidebarItem + key={item.key} + item={item} + currentPage={currentKey} + onClick={props.onClick} + /> + ))} + + {props.joinOrAddQuestionsButton} + </nav> + ) +} 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: () => ( + <Avatar + className="mx-auto my-1" + size="xs" + username={user.username} + avatarUrl={user.avatarUrl} + noLink + /> + ), +}) + // From https://codepen.io/chris__sev/pen/QWGvYbL export function BottomNavBar() { const [sidebarOpen, setSidebarOpen] = useState(false) @@ -61,20 +79,7 @@ export function BottomNavBar() { <NavBarItem key={'profile'} currentPage={currentPage} - item={{ - name: formatMoney(user.balance), - trackingEventName: 'profile', - href: `/${user.username}?tab=trades`, - icon: () => ( - <Avatar - className="mx-auto my-1" - size="xs" - username={user.username} - avatarUrl={user.avatarUrl} - noLink - /> - ), - }} + item={userProfileItem(user)} /> )} <div @@ -98,7 +103,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) { const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`) return ( - <Link href={item.href}> + <Link href={item.href ?? '#'}> <a className={clsx( 'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700', diff --git a/web/components/nav/profile-menu.tsx b/web/components/nav/profile-menu.tsx index e7cc056f..cf91ac66 100644 --- a/web/components/nav/profile-menu.tsx +++ b/web/components/nav/profile-menu.tsx @@ -4,11 +4,12 @@ import { User } from 'web/lib/firebase/users' import { formatMoney } from 'common/util/format' import { Avatar } from '../avatar' import { trackCallback } from 'web/lib/service/analytics' +import { PAST_BETS } from 'common/user' export function ProfileSummary(props: { user: User }) { const { user } = props return ( - <Link href={`/${user.username}?tab=trades`}> + <Link href={`/${user.username}?tab=${PAST_BETS}`}> <a onClick={trackCallback('sidebar: profile')} className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700" diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index d7adfa28..2a707683 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -13,7 +13,7 @@ import Router, { useRouter } from 'next/router' import { useUser } from 'web/hooks/use-user' import { firebaseLogout, User } from 'web/lib/firebase/users' import { ManifoldLogo } from './manifold-logo' -import { MenuButton } from './menu' +import { MenuButton, MenuItem } from './menu' import { ProfileSummary } from './profile-menu' import NotificationsIcon from 'web/components/notifications-icon' import React from 'react' @@ -139,7 +139,7 @@ function getMoreMobileNav() { } if (IS_PRIVATE_MANIFOLD) return [signOut] - return buildArray<Item>( + return buildArray<MenuItem>( CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, [ { name: 'Groups', href: '/groups' }, @@ -156,39 +156,59 @@ function getMoreMobileNav() { export type Item = { name: string trackingEventName?: string - href: string + href?: string + key?: string icon?: React.ComponentType<{ className?: string }> } -function SidebarItem(props: { item: Item; currentPage: string }) { - const { item, currentPage } = props - return ( - <Link href={item.href} key={item.name}> - <a - onClick={trackCallback('sidebar: ' + item.name)} - className={clsx( - item.href == currentPage - ? 'bg-gray-200 text-gray-900' - : 'text-gray-600 hover:bg-gray-100', - 'group flex items-center rounded-md px-3 py-2 text-sm font-medium' - )} - aria-current={item.href == currentPage ? 'page' : undefined} - > - {item.icon && ( - <item.icon - className={clsx( - item.href == currentPage - ? 'text-gray-500' - : 'text-gray-400 group-hover:text-gray-500', - '-ml-1 mr-3 h-6 w-6 flex-shrink-0' - )} - aria-hidden="true" - /> - )} - <span className="truncate">{item.name}</span> - </a> - </Link> +export function SidebarItem(props: { + item: Item + currentPage: string + onClick?: (key: string) => void +}) { + const { item, currentPage, onClick } = props + const isCurrentPage = + item.href != null ? item.href === currentPage : item.key === currentPage + + const sidebarItem = ( + <a + onClick={trackCallback('sidebar: ' + item.name)} + className={clsx( + isCurrentPage + ? 'bg-gray-200 text-gray-900' + : 'text-gray-600 hover:bg-gray-100', + 'group flex items-center rounded-md px-3 py-2 text-sm font-medium' + )} + aria-current={item.href == currentPage ? 'page' : undefined} + > + {item.icon && ( + <item.icon + className={clsx( + isCurrentPage + ? 'text-gray-500' + : 'text-gray-400 group-hover:text-gray-500', + '-ml-1 mr-3 h-6 w-6 flex-shrink-0' + )} + aria-hidden="true" + /> + )} + <span className="truncate">{item.name}</span> + </a> ) + + if (item.href) { + return ( + <Link href={item.href} key={item.name}> + {sidebarItem} + </Link> + ) + } else { + return onClick ? ( + <button onClick={() => onClick(item.key ?? '#')}>{sidebarItem}</button> + ) : ( + <> </> + ) + } } function SidebarButton(props: { 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<keyof notification_subscription_types> = [ + const emailsEnabled: Array<notification_preference> = [ 'all_comments_on_watched_markets', 'all_replies_to_my_comments_on_watched_markets', 'all_comments_on_contracts_with_shares_in_on_watched_markets', @@ -62,7 +63,6 @@ export function NotificationSettings(props: { 'contract_from_followed_user', 'unique_bettors_on_your_contract', // TODO: add these - // one-click unsubscribe only unsubscribes them from that type only, (well highlighted), then a link to manage the rest of their notifications // 'profit_loss_updates', - changes in markets you have shares in // biggest winner, here are the rest of your markets @@ -74,7 +74,7 @@ export function NotificationSettings(props: { // 'probability_updates_on_watched_markets', // 'limit_order_fills', ] - const browserDisabled: Array<keyof notification_subscription_types> = [ + const browserDisabled: Array<notification_preference> = [ 'trending_markets', 'profit_loss_updates', 'onboarding_flow', @@ -83,91 +83,82 @@ export function NotificationSettings(props: { type SectionData = { label: string - subscriptionTypeToDescription: { - [key in keyof Partial<notification_subscription_types>]: string - } + subscriptionTypes: Partial<notification_preference>[] } const comments: SectionData = { label: 'New Comments', - subscriptionTypeToDescription: { - all_comments_on_watched_markets: 'All new comments', - all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, + subscriptionTypes: [ + 'all_comments_on_watched_markets', + 'all_comments_on_contracts_with_shares_in_on_watched_markets', // TODO: combine these two - all_replies_to_my_comments_on_watched_markets: - 'Only replies to your comments', - all_replies_to_my_answers_on_watched_markets: - 'Only replies to your answers', - // comments_by_followed_users_on_watched_markets: 'By followed users', - }, + 'all_replies_to_my_comments_on_watched_markets', + 'all_replies_to_my_answers_on_watched_markets', + ], } const answers: SectionData = { label: 'New Answers', - subscriptionTypeToDescription: { - all_answers_on_watched_markets: 'All new answers', - all_answers_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, - // answers_by_followed_users_on_watched_markets: 'By followed users', - // answers_by_market_creator_on_watched_markets: 'By market creator', - }, + subscriptionTypes: [ + 'all_answers_on_watched_markets', + 'all_answers_on_contracts_with_shares_in_on_watched_markets', + ], } const updates: SectionData = { label: 'Updates & Resolutions', - subscriptionTypeToDescription: { - market_updates_on_watched_markets: 'All creator updates', - market_updates_on_watched_markets_with_shares_in: `Only creator updates on markets you're invested in`, - resolutions_on_watched_markets: 'All market resolutions', - resolutions_on_watched_markets_with_shares_in: `Only market resolutions you're invested in`, - // probability_updates_on_watched_markets: 'Probability updates', - }, + subscriptionTypes: [ + 'market_updates_on_watched_markets', + 'market_updates_on_watched_markets_with_shares_in', + 'resolutions_on_watched_markets', + 'resolutions_on_watched_markets_with_shares_in', + ], } const yourMarkets: SectionData = { label: 'Markets You Created', - subscriptionTypeToDescription: { - your_contract_closed: 'Your market has closed (and needs resolution)', - all_comments_on_my_markets: 'Comments on your markets', - all_answers_on_my_markets: 'Answers on your markets', - subsidized_your_market: 'Your market was subsidized', - tips_on_your_markets: 'Likes on your markets', - }, + subscriptionTypes: [ + 'your_contract_closed', + 'all_comments_on_my_markets', + 'all_answers_on_my_markets', + 'subsidized_your_market', + 'tips_on_your_markets', + ], } const bonuses: SectionData = { label: 'Bonuses', - subscriptionTypeToDescription: { - betting_streaks: 'Prediction streak bonuses', - referral_bonuses: 'Referral bonuses from referring users', - unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets', - }, + subscriptionTypes: [ + 'betting_streaks', + 'referral_bonuses', + 'unique_bettors_on_your_contract', + ], } const otherBalances: SectionData = { label: 'Other', - subscriptionTypeToDescription: { - loan_income: 'Automatic loans from your profitable bets', - limit_order_fills: 'Limit order fills', - tips_on_your_comments: 'Tips on your comments', - }, + subscriptionTypes: [ + 'loan_income', + 'limit_order_fills', + 'tips_on_your_comments', + ], } const userInteractions: SectionData = { label: 'Users', - subscriptionTypeToDescription: { - tagged_user: 'A user tagged you', - on_new_follow: 'Someone followed you', - contract_from_followed_user: 'New markets created by users you follow', - }, + subscriptionTypes: [ + 'tagged_user', + 'on_new_follow', + 'contract_from_followed_user', + ], } const generalOther: SectionData = { label: 'Other', - subscriptionTypeToDescription: { - trending_markets: 'Weekly interesting markets', - thank_you_for_purchases: 'Thank you notes for your purchases', - onboarding_flow: 'Explanatory emails to help you get started', - // profit_loss_updates: 'Weekly profit/loss updates', - }, + subscriptionTypes: [ + 'trending_markets', + 'thank_you_for_purchases', + 'onboarding_flow', + ], } function NotificationSettingLine(props: { description: string - subscriptionTypeKey: keyof notification_subscription_types + subscriptionTypeKey: notification_preference destinations: notification_destination_types[] }) { const { description, subscriptionTypeKey, destinations } = props @@ -237,9 +228,7 @@ export function NotificationSettings(props: { ) } - const getUsersSavedPreference = ( - key: keyof notification_subscription_types - ) => { + const getUsersSavedPreference = (key: notification_preference) => { return privateUser.notificationPreferences[key] ?? [] } @@ -248,17 +237,15 @@ export function NotificationSettings(props: { data: SectionData }) { const { icon, data } = props - const { label, subscriptionTypeToDescription } = data + const { label, subscriptionTypes } = data const expand = navigateToSection && - Object.keys(subscriptionTypeToDescription).includes(navigateToSection) + subscriptionTypes.includes(navigateToSection as notification_preference) // Not sure how to prevent re-render (and collapse of an open section) // due to a private user settings change. Just going to persist expanded state here const [expanded, setExpanded] = usePersistentState(expand ?? false, { - key: - 'NotificationsSettingsSection-' + - Object.keys(subscriptionTypeToDescription).join('-'), + key: 'NotificationsSettingsSection-' + subscriptionTypes.join('-'), store: storageStore(safeLocalStorage()), }) @@ -287,13 +274,13 @@ export function NotificationSettings(props: { )} </Row> <Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}> - {Object.entries(subscriptionTypeToDescription).map(([key, value]) => ( + {subscriptionTypes.map((subType) => ( <NotificationSettingLine - subscriptionTypeKey={key as keyof notification_subscription_types} + subscriptionTypeKey={subType as notification_preference} destinations={getUsersSavedPreference( - key as keyof notification_subscription_types + subType as notification_preference )} - description={value} + description={NOTIFICATION_DESCRIPTIONS[subType].simple} /> ))} </Col> 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 ( - <Col className={clsx('rounded-md bg-white px-8 py-6', className)}> - <div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div> + <Col + className={clsx( + 'relative w-full rounded-md bg-white px-8 py-6', + className + )} + > + {isAdmin && !isCreator && ( + <span className="absolute right-4 top-4 rounded bg-red-200 p-1 text-xs text-red-600"> + ADMIN + </span> + )} + <div className="whitespace-nowrap text-2xl">Resolve market</div> - <div className="mb-3 text-sm text-gray-500">Outcome</div> + <div className="my-3 text-sm text-gray-500">Outcome</div> <Spacer h={4} /> @@ -99,9 +112,12 @@ export function NumericResolutionPanel(props: { <div> {outcome === 'CANCEL' ? ( - <>All trades will be returned with no fees.</> + <> + All {PAST_BETS} will be returned. Unique {BETTOR} bonuses will be + withdrawn from your account + </> ) : ( - <>Resolving this market will immediately pay out traders.</> + <>Resolving this market will immediately pay out {BETTORS}.</> )} </div> 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: { <Modal open={isOpen} setOpen={setOpen}> <Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> <span className={'text-8xl'}>🏦</span> - <span className="text-xl">Daily loans on your trades</span> + <span className="text-xl">Daily loans on your {PAST_BETS}</span> <Col className={'gap-2'}> <span className={'text-indigo-700'}>• What are daily loans?</span> <span className={'ml-2'}> 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<any> - color?: ColorType -}) { - const { children, onClick, color } = props - return ( - <Button - color={color} - size="lg" - onClick={onClick} - className="btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case" - > - {children} - </Button> - ) -} - -export function TwitchPanel() { - const user = useUser() - const privateUser = usePrivateUser() - - const twitchInfo = privateUser?.twitchInfo - const twitchName = privateUser?.twitchInfo?.twitchName - const twitchToken = privateUser?.twitchInfo?.controlToken - const twitchBotConnected = privateUser?.twitchInfo?.botEnabled - - const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> - - const copyOverlayLink = async () => { - copyToClipboard(`http://localhost:1000/overlay?t=${twitchToken}`) - toast.success('Overlay link copied!', { - icon: linkIcon, - }) - } - - const copyDockLink = async () => { - copyToClipboard(`http://localhost:1000/dock?t=${twitchToken}`) - toast.success('Dock link copied!', { - icon: linkIcon, - }) - } - - const updateBotConnected = (connected: boolean) => async () => { - if (user && twitchInfo) { - twitchInfo.botEnabled = connected - await updatePrivateUser(user.id, { twitchInfo }) - } - } - - const [twitchLoading, setTwitchLoading] = useState(false) - - const createLink = async () => { - if (!user || !privateUser) return - setTwitchLoading(true) - - const promise = linkTwitchAccountRedirect(user, privateUser) - track('link twitch from profile') - await promise - - setTwitchLoading(false) - } - - return ( - <> - <div> - <label className="label">Twitch</label> - - {!twitchName ? ( - <Row> - <Button - color="indigo" - onClick={createLink} - disabled={twitchLoading} - > - Link your Twitch account - </Button> - {twitchLoading && <LoadingIndicator className="ml-4" />} - </Row> - ) : ( - <Row> - <span className="mr-4 text-gray-500">Linked Twitch account</span>{' '} - {twitchName} - </Row> - )} - </div> - - {twitchToken && ( - <div> - <div className="flex w-full"> - <div - className={clsx( - 'flex grow gap-4', - twitchToken ? '' : 'tooltip tooltip-top' - )} - data-tip="You must link your Twitch account first" - > - <BouncyButton color="blue" onClick={copyOverlayLink}> - Copy overlay link - </BouncyButton> - <BouncyButton color="indigo" onClick={copyDockLink}> - Copy dock link - </BouncyButton> - {twitchBotConnected ? ( - <BouncyButton color="red" onClick={updateBotConnected(false)}> - Remove bot from your channel - </BouncyButton> - ) : ( - <BouncyButton color="green" onClick={updateBotConnected(true)}> - Add bot to your channel - </BouncyButton> - )} - </div> - </div> - </div> - )} - </> - ) -} 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 ( - <Col className={clsx('rounded-md bg-white px-8 py-6', className)}> + <Col className={clsx('relative rounded-md bg-white px-8 py-6', className)}> + {isAdmin && !isCreator && ( + <span className="absolute right-4 top-4 rounded bg-red-200 p-1 text-xs text-red-600"> + ADMIN + </span> + )} <div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div> <div className="mb-3 text-sm text-gray-500">Outcome</div> @@ -83,23 +91,28 @@ export function ResolutionPanel(props: { <div> {outcome === 'YES' ? ( <> - Winnings will be paid out to traders who bought YES. + Winnings will be paid out to {BETTORS} who bought YES. {/* <br /> <br /> You will earn {earnedFees}. */} </> ) : outcome === 'NO' ? ( <> - Winnings will be paid out to traders who bought NO. + Winnings will be paid out to {BETTORS} who bought NO. {/* <br /> <br /> You will earn {earnedFees}. */} </> ) : outcome === 'CANCEL' ? ( - <>All trades will be returned with no fees.</> + <> + All {PAST_BETS} will be returned. Unique {BETTOR} bonuses will be + withdrawn from your account + </> ) : outcome === 'MKT' ? ( <Col className="gap-6"> - <div>Traders will be paid out at the probability you specify:</div> + <div> + {PAST_BETS} will be paid out at the probability you specify: + </div> <ProbabilitySelector probabilityInt={Math.round(prob)} setProbabilityInt={setProb} @@ -107,7 +120,7 @@ export function ResolutionPanel(props: { {/* You will earn {earnedFees}. */} </Col> ) : ( - <>Resolving this market will immediately pay out traders.</> + <>Resolving this market will immediately pay out {BETTORS}.</> )} </div> 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 ( <SiteLink href={`/${username}`} - className={clsx('z-10 truncate', className)} + className={clsx( + 'z-10 truncate', + className, + noLink ? 'pointer-events-none' : '' + )} > {shortName} </SiteLink> 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 }) { <SiteLink href="/referrals"> Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend! </SiteLink>{' '} - You have <ReferralsButton user={user} currentUser={currentUser} /> + You've gotten{' '} + <ReferralsButton user={user} currentUser={currentUser} /> </span> <ShareIconButton copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`} @@ -269,7 +272,7 @@ export function UserPage(props: { user: User }) { ), }, { - title: 'Trades', + title: capitalize(PAST_BETS), content: ( <> <BetsList user={user} /> 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 ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + className={props.className} + > + <polyline points="15 10 20 15 15 20"></polyline> + <path d="M4 4v7a4 4 0 0 0 4 4h12"></path> + </svg> + ) +} 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 ? ( - <NumericResolutionPanel creator={user} contract={contract} /> + <NumericResolutionPanel + isAdmin={isAdmin} + creator={user} + isCreator={isCreator} + contract={contract} + /> ) : ( - <ResolutionPanel creator={user} contract={contract} /> + <ResolutionPanel + isAdmin={isAdmin} + creator={user} + isCreator={isCreator} + contract={contract} + /> ))} </Col> ) : null @@ -154,10 +174,8 @@ export function ContractPageContent( } ) { const { backToHome, comments, user } = props - const contract = useContractWithPreload(props.contract) ?? props.contract usePrefetch(user?.id) - useTracking( 'view market', { @@ -238,7 +256,6 @@ export function ContractPageContent( )} <ContractOverview contract={contract} bets={nonChallengeBets} /> - <ExtraContractActionsRow contract={contract} /> <ContractDescription className="mb-6 px-2" contract={contract} /> {outcomeType === 'NUMERIC' && ( 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: { > <option value="score">Trending</option> <option value="newest">Newest</option> - <option value="most-traded">Most traded</option> + <option value="most-traded">Most ${PAST_BETS}</option> <option value="24-hour-vol">24h volume</option> <option value="close-date">Closing soon</option> </select> 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 ( <Col className="h-[100vh] w-full bg-white"> - <div className="relative flex flex-col pt-2"> - <div className="px-3 text-xl text-indigo-700 md:text-2xl"> + <Row className="justify-between gap-4 px-2"> + <div className="text-xl text-indigo-700 md:text-2xl"> <SiteLink href={href}>{question}</SiteLink> </div> + {isBinary && ( + <BinaryResolutionOrChance contract={contract} probAfter={probAfter} /> + )} - <Spacer h={3} /> + {isPseudoNumeric && ( + <PseudoNumericResolutionOrExpectation contract={contract} /> + )} - <Row className="items-center justify-between gap-4 px-2"> - <ContractDetails contract={contract} disabled /> + {outcomeType === 'FREE_RESPONSE' && ( + <FreeResponseResolutionOrChance contract={contract} truncate="long" /> + )} - {(isBinary || isPseudoNumeric) && - tradingAllowed(contract) && - !betPanelOpen && ( - <Button color="gradient" onClick={() => setBetPanelOpen(true)}> - Bet - </Button> - )} + {outcomeType === 'NUMERIC' && ( + <NumericResolutionOrExpectation contract={contract} /> + )} + </Row> + <Spacer h={3} /> + <Row className="items-center justify-between gap-4 px-2"> + <MarketSubheader contract={contract} disabled /> - {isBinary && ( - <BinaryResolutionOrChance - contract={contract} - probAfter={probAfter} - className="items-center" - /> + {(isBinary || isPseudoNumeric) && + tradingAllowed(contract) && + !betPanelOpen && ( + <Button color="gradient" onClick={() => setBetPanelOpen(true)}> + Predict + </Button> )} + </Row> - {isPseudoNumeric && ( - <PseudoNumericResolutionOrExpectation contract={contract} /> - )} - - {outcomeType === 'FREE_RESPONSE' && ( - <FreeResponseResolutionOrChance - contract={contract} - truncate="long" - /> - )} - - {outcomeType === 'NUMERIC' && ( - <NumericResolutionOrExpectation contract={contract} /> - )} - </Row> - - <Spacer h={2} /> - </div> + <Spacer h={2} /> {(isBinary || isPseudoNumeric) && betPanelOpen && ( <BetInline diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index bf4a17e4..7b0ca70c 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -148,6 +148,7 @@ function SearchSection(props: { defaultPill={pill} noControls maxResults={6} + headerClassName="sticky" persistPrefix={`experimental-home-${sort}`} /> </Col> 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 = ( <Col> <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> <GroupLeaderboard topUsers={topTraders} - title="🏅 Top traders" + title={`🏅 Top ${BETTORS}`} header="Profit" maxToShow={maxLeaderboardSize} /> @@ -169,7 +173,7 @@ export default function GroupPage(props: { </Col> ) - const aboutTab = ( + const aboutPage = ( <Col> {(group.aboutPostId != null || isCreator || isAdmin) && ( <GroupAboutPost @@ -189,73 +193,118 @@ export default function GroupPage(props: { </Col> ) - const questionsTab = ( - <ContractSearch - user={user} - defaultSort={'newest'} - defaultFilter={suggestedFilter} - additionalFilter={{ groupSlug: group.slug }} - persistPrefix={`group-${group.slug}`} - /> + const questionsPage = ( + <> + {/* align the divs to the right */} + <div className={' flex justify-end px-2 pb-2 sm:hidden'}> + <div> + <JoinOrAddQuestionsButtons + group={group} + user={user} + isMember={!!isMember} + /> + </div> + </div> + <ContractSearch + headerClassName="md:sticky" + user={user} + defaultSort={'newest'} + defaultFilter={suggestedFilter} + additionalFilter={{ groupSlug: group.slug }} + persistPrefix={`group-${group.slug}`} + /> + </> ) - const tabs = [ + const sidebarPages = [ { title: 'Markets', - content: questionsTab, + content: questionsPage, href: groupPath(group.slug, 'markets'), + key: 'markets', }, { title: 'Leaderboards', - content: leaderboard, + content: leaderboardPage, href: groupPath(group.slug, 'leaderboards'), + key: 'leaderboards', }, { title: 'About', - content: aboutTab, + content: aboutPage, href: groupPath(group.slug, 'about'), + key: 'about', }, ] - const tabIndex = tabs - .map((t) => t.title.toLowerCase()) - .indexOf(page ?? 'markets') + const pageContent = sidebarPages[sidebarIndex].content + const onSidebarClick = (key: string) => { + const index = sidebarPages.findIndex((t) => t.key === key) + setSidebarIndex(index) + } + + const joinOrAddQuestionsButton = ( + <JoinOrAddQuestionsButtons + group={group} + user={user} + isMember={!!isMember} + /> + ) return ( - <Page> - <SEO - title={group.name} - description={`Created by ${creator.name}. ${group.about}`} - url={groupPath(group.slug)} - /> - <Col className="relative px-3"> - <Row className={'items-center justify-between gap-4'}> - <div className={'sm:mb-1'}> - <div - className={'line-clamp-1 my-2 text-2xl text-indigo-700 sm:my-3'} - > - {group.name} - </div> - <div className={'hidden sm:block'}> - <Linkify text={group.about} /> - </div> - </div> - <div className="mt-2"> - <JoinOrAddQuestionsButtons - group={group} - user={user} - isMember={!!isMember} - /> - </div> - </Row> - </Col> - <Tabs - currentPageForAnalytics={groupPath(group.slug)} - className={'mx-2 mb-0 sm:mb-2'} - defaultIndex={tabIndex > 0 ? tabIndex : 0} - tabs={tabs} - /> - </Page> + <> + <TopGroupNavBar group={group} /> + <div> + <div + className={ + 'mx-auto w-full pb-[58px] lg:grid lg:grid-cols-12 lg:gap-x-2 lg:pb-0 xl:max-w-7xl xl:gap-x-8' + } + > + <Toaster /> + <GroupSidebar + groupName={group.name} + className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex" + onClick={onSidebarClick} + joinOrAddQuestionsButton={joinOrAddQuestionsButton} + currentKey={sidebarPages[sidebarIndex].key} + /> + + <SEO + title={group.name} + description={`Created by ${creator.name}. ${group.about}`} + url={groupPath(group.slug)} + /> + <main className={'px-2 pt-1 lg:col-span-8 lg:pt-6 xl:col-span-8'}> + {pageContent} + </main> + </div> + <GroupNavBar + currentPage={sidebarPages[sidebarIndex].key} + onClick={onSidebarClick} + /> + </div> + </> + ) +} + +export function TopGroupNavBar(props: { group: Group }) { + return ( + <header className="sticky top-0 z-50 w-full pb-2 md:hidden lg:col-span-12"> + <div className="flex items-center border-b border-gray-200 bg-white px-4"> + <div className="flex-shrink-0"> + <Link href="/"> + <a className="text-indigo-700 hover:text-gray-500 "> + <ArrowLeftIcon className="h-5 w-5" aria-hidden="true" /> + </a> + </Link> + </div> + <div className="ml-3"> + <h1 className="text-lg font-medium text-indigo-700"> + {props.group.name} + </h1> + </div> + </div> + </header> ) } @@ -263,10 +312,11 @@ function JoinOrAddQuestionsButtons(props: { group: Group user: User | null | undefined isMember: boolean + className?: string }) { const { group, user, isMember } = props return user && isMember ? ( - <Row className={'mt-0 justify-end'}> + <Row className={'w-full self-start pt-4'}> <AddContractButton group={group} user={user} /> </Row> ) : group.anyoneCanJoin ? ( @@ -410,9 +460,9 @@ function AddContractButton(props: { group: Group; user: User }) { return ( <> - <div className={'flex justify-center'}> + <div className={'flex w-full justify-center'}> <Button - className="whitespace-nowrap" + className="w-full whitespace-nowrap" size="md" color="indigo" onClick={() => setOpen(true)} @@ -467,7 +517,9 @@ function JoinGroupButton(props: { <div> <button onClick={follow} - className={'btn-md btn-outline btn whitespace-nowrap normal-case'} + className={ + 'btn-md btn-outline btn w-full whitespace-nowrap normal-case' + } > Follow </button> 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" /> </Col> <button diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 08819833..4f1e9437 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -14,6 +14,7 @@ import { Title } from 'web/components/title' import { Tabs } from 'web/components/layout/tabs' import { useTracking } from 'web/hooks/use-tracking' import { SEO } from 'web/components/SEO' +import { BETTORS } from 'common/user' export async function getStaticProps() { const props = await fetchProps() @@ -79,7 +80,7 @@ export default function Leaderboards(_props: { <> <Col className="mx-4 items-center gap-10 lg:flex-row"> <Leaderboard - title="🏅 Top traders" + title={`🏅 Top ${BETTORS}`} users={topTraders} columns={[ { @@ -126,7 +127,7 @@ export default function Leaderboards(_props: { <Page> <SEO title="Leaderboards" - description="Manifold's leaderboards show the top traders and market creators." + description={`Manifold's leaderboards show the top ${BETTORS} and market creators.`} url="/leaderboards" /> <Title text={'Leaderboards'} className={'hidden md:block'} /> 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: { <NotificationItem notification={notification.notifications[0]} key={notification.notifications[0].id} + justSummary={false} /> ) : ( <NotificationGroupItem @@ -697,20 +703,11 @@ function NotificationGroupItem(props: { function NotificationItem(props: { notification: Notification - justSummary?: boolean + justSummary: boolean isChildOfGroup?: boolean }) { const { notification, justSummary, isChildOfGroup } = props - const { - sourceType, - sourceUserName, - sourceUserAvatarUrl, - sourceUpdateType, - reasonText, - reason, - sourceUserUsername, - sourceText, - } = notification + const { sourceType, reason, sourceUpdateType } = notification const [highlighted] = useState(!notification.isSeen) @@ -718,39 +715,113 @@ function NotificationItem(props: { setNotificationsAsSeen([notification]) }, [notification]) - const questionNeedsResolution = sourceUpdateType == 'closed' + // TODO Any new notification should be its own component + if (reason === 'bet_fill') { + return ( + <BetFillNotification + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + justSummary={justSummary} + /> + ) + } else if (sourceType === 'contract' && sourceUpdateType === 'resolved') { + return ( + <ContractResolvedNotification + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + justSummary={justSummary} + /> + ) + } + // TODO Add new notification components here if (justSummary) { return ( - <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> - <div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}> - <div className={'flex pl-1 sm:pl-0'}> - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-0 flex-shrink-0'} - short={true} - /> - <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> - <span className={'flex-shrink-0'}> - {sourceType && - reason && - getReasonForShowingNotification(notification, true)} - </span> - <div className={'ml-1 text-black'}> - <NotificationTextLabel - className={'line-clamp-1'} - notification={notification} - justSummary={true} - /> - </div> - </div> - </div> - </div> - </Row> + <NotificationSummaryFrame + notification={notification} + subtitle={ + (sourceType && + reason && + getReasonForShowingNotification(notification, true)) ?? + '' + } + > + <NotificationTextLabel + className={'line-clamp-1'} + notification={notification} + justSummary={true} + /> + </NotificationSummaryFrame> ) } + return ( + <NotificationFrame + notification={notification} + subtitle={getReasonForShowingNotification( + notification, + isChildOfGroup ?? false + )} + highlighted={highlighted} + > + <div className={'mt-1 ml-1 md:text-base'}> + <NotificationTextLabel notification={notification} /> + </div> + </NotificationFrame> + ) +} + +function NotificationSummaryFrame(props: { + notification: Notification + subtitle: string + children: React.ReactNode +}) { + const { notification, subtitle, children } = props + const { sourceUserName, sourceUserUsername } = notification + return ( + <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> + <div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}> + <div className={'flex pl-1 sm:pl-0'}> + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-0 flex-shrink-0'} + short={true} + /> + <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> + <span className={'flex-shrink-0'}>{subtitle}</span> + <div className={'line-clamp-1 ml-1 text-black'}>{children}</div> + </div> + </div> + </div> + </Row> + ) +} + +function NotificationFrame(props: { + notification: Notification + highlighted: boolean + subtitle: string + children: React.ReactNode + isChildOfGroup?: boolean +}) { + const { notification, isChildOfGroup, highlighted, subtitle, children } = + props + const { + sourceType, + sourceUserName, + sourceUserAvatarUrl, + sourceUpdateType, + reason, + reasonText, + sourceUserUsername, + sourceText, + } = notification + const questionNeedsResolution = sourceUpdateType == 'closed' + const { width } = useWindowSize() + const isMobile = (width ?? 0) < 600 return ( <div className={clsx( @@ -796,18 +867,13 @@ function NotificationItem(props: { } > <div> - {!questionNeedsResolution && ( - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'relative mr-1 flex-shrink-0'} - short={true} - /> - )} - {getReasonForShowingNotification( - notification, - isChildOfGroup ?? false - )} + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'relative mr-1 flex-shrink-0'} + short={isMobile} + /> + {subtitle} {isChildOfGroup ? ( <RelativeTimestamp time={notification.createdTime} /> ) : ( @@ -822,9 +888,7 @@ function NotificationItem(props: { )} </div> </Row> - <div className={'mt-1 ml-1 md:text-base'}> - <NotificationTextLabel notification={notification} /> - </div> + <div className={'mt-1 ml-1 md:text-base'}>{children}</div> <div className={'mt-6 border-b border-gray-300'} /> </div> @@ -832,6 +896,148 @@ function NotificationItem(props: { ) } +function BetFillNotification(props: { + notification: Notification + highlighted: boolean + justSummary: boolean + isChildOfGroup?: boolean +}) { + const { notification, isChildOfGroup, highlighted, justSummary } = props + const { sourceText, data } = notification + const { creatorOutcome, probability, limitOrderTotal, limitOrderRemaining } = + (data as BetFillData) ?? {} + const subtitle = 'bet against you' + const amount = formatMoney(parseInt(sourceText ?? '0')) + const description = + creatorOutcome && probability ? ( + <span> + of your {limitOrderTotal ? formatMoney(limitOrderTotal) : ''} + <span + className={clsx( + 'mx-1', + creatorOutcome === 'YES' + ? 'text-primary' + : creatorOutcome === 'NO' + ? 'text-red-500' + : 'text-blue-500' + )} + > + {creatorOutcome} + </span> + limit order at {Math.round(probability * 100)}% was filled{' '} + {limitOrderRemaining + ? `(${formatMoney(limitOrderRemaining)} remaining)` + : ''} + </span> + ) : ( + <span>of your limit order was filled</span> + ) + + if (justSummary) { + return ( + <NotificationSummaryFrame notification={notification} subtitle={subtitle}> + <Row className={'line-clamp-1'}> + <span className={'text-primary mr-1'}>{amount}</span> + <span>{description}</span> + </Row> + </NotificationSummaryFrame> + ) + } + + return ( + <NotificationFrame + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + subtitle={subtitle} + > + <Row> + <span> + <span className="text-primary mr-1">{amount}</span> + {description} + </span> + </Row> + </NotificationFrame> + ) +} + +function ContractResolvedNotification(props: { + notification: Notification + highlighted: boolean + justSummary: boolean + isChildOfGroup?: boolean +}) { + const { notification, isChildOfGroup, highlighted, justSummary } = props + const { sourceText, data } = notification + const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {} + const subtitle = 'resolved the market' + const resolutionDescription = () => { + if (!sourceText) return <div /> + if (sourceText === 'YES' || sourceText == 'NO') { + return <BinaryOutcomeLabel outcome={sourceText as any} /> + } + if (sourceText.includes('%')) + return <ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} /> + if (sourceText === 'CANCEL') return <CancelLabel /> + if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel /> + + // Numeric market + if (parseFloat(sourceText)) + return <NumericValueLabel value={parseFloat(sourceText)} /> + + // Free response market + return ( + <div className={'line-clamp-1 text-blue-400'}> + <Linkify text={sourceText} /> + </div> + ) + } + + const description = + userInvestment && userPayout !== undefined ? ( + <Row className={'gap-1 '}> + {resolutionDescription()} + Invested: + <span className={'text-primary'}>{formatMoney(userInvestment)} </span> + Payout: + <span + className={clsx( + userPayout > 0 ? 'text-primary' : 'text-red-500', + 'truncate' + )} + > + {formatMoney(userPayout)} + {` (${userPayout > 0 ? '+' : ''}${Math.round( + ((userPayout - userInvestment) / userInvestment) * 100 + )}%)`} + </span> + </Row> + ) : ( + <span>{resolutionDescription()}</span> + ) + + if (justSummary) { + return ( + <NotificationSummaryFrame notification={notification} subtitle={subtitle}> + <Row className={'line-clamp-1'}>{description}</Row> + </NotificationSummaryFrame> + ) + } + + return ( + <NotificationFrame + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + subtitle={subtitle} + > + <Row> + <span>{description}</span> + </Row> + </NotificationFrame> + ) +} + export const setNotificationsAsSeen = async (notifications: Notification[]) => { const unseenNotifications = notifications.filter((n) => !n.isSeen) return await Promise.all( @@ -951,30 +1157,7 @@ function NotificationTextLabel(props: { if (sourceType === 'contract') { if (justSummary || !sourceText) return <div /> // Resolved contracts - if (sourceType === 'contract' && sourceUpdateType === 'resolved') { - { - if (sourceText === 'YES' || sourceText == 'NO') { - return <BinaryOutcomeLabel outcome={sourceText as any} /> - } - if (sourceText.includes('%')) - return ( - <ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} /> - ) - if (sourceText === 'CANCEL') return <CancelLabel /> - if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel /> - // Numeric market - if (parseFloat(sourceText)) - return <NumericValueLabel value={parseFloat(sourceText)} /> - - // Free response market - return ( - <div className={className ? className : 'line-clamp-1 text-blue-400'}> - <Linkify text={sourceText} /> - </div> - ) - } - } // Close date will be a number - it looks better without it if (sourceUpdateType === 'closed') { return <div /> @@ -1002,15 +1185,6 @@ function NotificationTextLabel(props: { return ( <span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span> ) - } else if (sourceType === 'bet' && sourceText) { - return ( - <> - <span className="text-primary"> - {formatMoney(parseInt(sourceText))} - </span>{' '} - <span>of your limit order was filled</span> - </> - ) } else if (sourceType === 'challenge' && sourceText) { return ( <> @@ -1074,9 +1248,6 @@ function getReasonForShowingNotification( else if (sourceSlug) reasonText = 'joined because you shared' else reasonText = 'joined because of you' break - case 'bet': - reasonText = 'bet against you' - break case 'challenge': reasonText = 'accepted your challenge' break 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 /> - <button - className="btn btn-primary btn-square p-2" - onClick={updateApiKey} + <ConfirmationButton + openModalBtn={{ + className: 'btn btn-primary btn-square p-2', + label: '', + icon: <RefreshIcon />, + }} + submitBtn={{ + label: 'Update key', + className: 'btn-primary', + }} + onSubmitWithSuccess={async () => { + updateApiKey() + return true + }} > - <RefreshIcon /> - </button> + <Col> + <Title text={'Are you sure?'} /> + <div> + Updating your API key will break any existing applications + connected to your account, <b>including the Twitch bot</b>. + You will need to go to the{' '} + <Link href="/twitch"> + <a className="underline focus:outline-none"> + Twitch page + </a> + </Link>{' '} + to relink your account. + </div> + </Col> + </ConfirmationButton> </div> </div> - - <TwitchPanel /> </Col> </Col> </Page> 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<Stats | undefined>(undefined) @@ -156,7 +158,7 @@ export function CustomAnalytics(props: { defaultIndex={0} tabs={[ { - title: 'Trades', + title: capitalize(PAST_BETS), content: ( <DailyCountChart dailyCounts={dailyBetCounts} diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index e81c239f..8ce11284 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -76,6 +76,13 @@ const Salem = { } const tourneys: Tourney[] = [ + { + title: 'Clearer Thinking Regrant Project', + blurb: 'Which projects will Clearer Thinking give a grant to?', + award: '$13,000', + endTime: toDate('Sep 22, 2022'), + groupId: 'fhksfIgqyWf7OxsV9nkM', + }, { title: 'Manifold F2P Tournament', blurb: @@ -99,13 +106,6 @@ const tourneys: Tourney[] = [ endTime: toDate('Jan 6, 2023'), groupId: 'SxGRqXRpV3RAQKudbcNb', }, - // { - // title: 'Clearer Thinking Regrant Project', - // blurb: 'Something amazing', - // award: '$10,000', - // endTime: toDate('Sep 22, 2022'), - // groupId: '2VsVVFGhKtIdJnQRAXVb', - // }, // Tournaments without awards get featured belows { diff --git a/web/pages/twitch.tsx b/web/pages/twitch.tsx index 7ca892e8..46856eaf 100644 --- a/web/pages/twitch.tsx +++ b/web/pages/twitch.tsx @@ -1,28 +1,48 @@ -import { useState } from 'react' +import { LinkIcon } from '@heroicons/react/solid' +import clsx from 'clsx' +import { PrivateUser, User } from 'common/user' +import Link from 'next/link' +import { MouseEventHandler, ReactNode, useState } from 'react' -import { Page } from 'web/components/page' -import { Col } from 'web/components/layout/col' -import { ManifoldLogo } from 'web/components/nav/manifold-logo' -import { useSaveReferral } from 'web/hooks/use-save-referral' -import { SEO } from 'web/components/SEO' -import { Spacer } from 'web/components/layout/spacer' -import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users' -import { track } from 'web/lib/service/analytics' -import { Row } from 'web/components/layout/row' -import { Button } from 'web/components/button' -import { useTracking } from 'web/hooks/use-tracking' -import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' -import { usePrivateUser, useUser } from 'web/hooks/use-user' -import { LoadingIndicator } from 'web/components/loading-indicator' import toast from 'react-hot-toast' +import { Button } from 'web/components/button' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { Spacer } from 'web/components/layout/spacer' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { ManifoldLogo } from 'web/components/nav/manifold-logo' +import { Page } from 'web/components/page' +import { SEO } from 'web/components/SEO' +import { Title } from 'web/components/title' +import { useSaveReferral } from 'web/hooks/use-save-referral' +import { useTracking } from 'web/hooks/use-tracking' +import { usePrivateUser, useUser } from 'web/hooks/use-user' +import { + firebaseLogin, + getUserAndPrivateUser, + updatePrivateUser, +} from 'web/lib/firebase/users' +import { track } from 'web/lib/service/analytics' +import { + getDockURLForUser, + getOverlayURLForUser, + linkTwitchAccountRedirect, + updateBotEnabledForUser, +} from 'web/lib/twitch/link-twitch-account' +import { copyToClipboard } from 'web/lib/util/copy' -export default function TwitchLandingPage() { - useSaveReferral() - useTracking('view twitch landing page') +function ButtonGetStarted(props: { + user?: User | null + privateUser?: PrivateUser | null + buttonClass?: string + spinnerClass?: string +}) { + const { user, privateUser, buttonClass, spinnerClass } = props - const user = useUser() - const privateUser = usePrivateUser() - const twitchUser = privateUser?.twitchInfo?.twitchName + const [isLoading, setLoading] = useState(false) + const needsRelink = + privateUser?.twitchInfo?.twitchName && + privateUser?.twitchInfo?.needsRelinking const callback = user && privateUser @@ -34,11 +54,11 @@ export default function TwitchLandingPage() { const { user, privateUser } = await getUserAndPrivateUser(userId) if (!user || !privateUser) return + if (privateUser.twitchInfo?.twitchName) return // If we've already linked Twitch, no need to do so again + await linkTwitchAccountRedirect(user, privateUser) } - const [isLoading, setLoading] = useState(false) - const getStarted = async () => { try { setLoading(true) @@ -49,9 +69,335 @@ export default function TwitchLandingPage() { } catch (e) { console.error(e) toast.error('Failed to sign up. Please try again later.') + } finally { setLoading(false) } } + return isLoading ? ( + <LoadingIndicator + spinnerClassName={clsx('!w-11 !h-11 my-4', spinnerClass)} + /> + ) : ( + <Button + size="xl" + color={needsRelink ? 'red' : 'gradient'} + className={clsx('my-4 self-center !px-16', buttonClass)} + onClick={getStarted} + > + {needsRelink ? 'API key updated: relink Twitch' : 'Start playing'} + </Button> + ) +} + +function TwitchPlaysManifoldMarkets(props: { + user?: User | null + privateUser?: PrivateUser | null +}) { + const { user, privateUser } = props + + const twitchInfo = privateUser?.twitchInfo + const twitchUser = twitchInfo?.twitchName + + return ( + <div> + <Row className="mb-4"> + <img + src="/twitch-glitch.svg" + className="mb-[0.4rem] mr-4 inline h-10 w-10" + ></img> + <Title + text={'Twitch plays Manifold Markets'} + className={'!-my-0 md:block'} + /> + </Row> + <Col className="gap-4"> + <div> + Similar to Twitch channel point predictions, Manifold Markets allows + you to create and feature on stream any question you like with users + predicting to earn play money. + </div> + <div> + The key difference is that Manifold's questions function more like a + stock market and viewers can buy and sell shares over the course of + the event and not just at the start. The market will eventually + resolve to yes or no at which point the winning shareholders will + receive their profit. + </div> + Start playing now by logging in with Google and typing commands in chat! + {twitchUser && !twitchInfo.needsRelinking ? ( + <Button + size="xl" + color="green" + className="btn-disabled my-4 self-center !border-none" + > + Account connected: {twitchUser} + </Button> + ) : ( + <ButtonGetStarted user={user} privateUser={privateUser} /> + )} + <div> + Instead of Twitch channel points we use our play money, Mana (M$). All + viewers start with M$1000 and more can be earned for free and then{' '} + <Link href="/charity"> + <a className="underline">donated to a charity</a> + </Link>{' '} + of their choice at no cost! + </div> + </Col> + </div> + ) +} + +function Subtitle(props: { text: string }) { + const { text } = props + return <div className="text-2xl">{text}</div> +} + +function Command(props: { command: string; desc: string }) { + const { command, desc } = props + return ( + <div> + <p className="inline font-bold">{'!' + command}</p> + {' - '} + <p className="inline">{desc}</p> + </div> + ) +} + +function TwitchChatCommands() { + return ( + <div> + <Title text="Twitch Chat Commands" className="md:block" /> + <Col className="gap-4"> + <Subtitle text="For Chat" /> + <Command command="bet yes#" desc="Bets a # of Mana on yes." /> + <Command command="bet no#" desc="Bets a # of Mana on no." /> + <Command + command="sell" + desc="Sells all shares you own. Using this command causes you to + cash out early before the market resolves. This could be profitable + (if the probability has moved towards the direction you bet) or cause + a loss, although at least you keep some Mana. For maximum profit (but + also risk) it is better to not sell and wait for a favourable + resolution." + /> + <Command command="balance" desc="Shows how much Mana you own." /> + <Command command="allin yes" desc="Bets your entire balance on yes." /> + <Command command="allin no" desc="Bets your entire balance on no." /> + + <Subtitle text="For Mods/Streamer" /> + <Command + command="create <question>" + desc="Creates and features the question. Be careful... this will override any question that is currently featured." + /> + <Command command="resolve yes" desc="Resolves the market as 'Yes'." /> + <Command command="resolve no" desc="Resolves the market as 'No'." /> + <Command + command="resolve n/a" + desc="Resolves the market as 'N/A' and refunds everyone their Mana." + /> + </Col> + </div> + ) +} + +function BotSetupStep(props: { + stepNum: number + buttonName?: string + buttonOnClick?: MouseEventHandler + overrideButton?: ReactNode + children: ReactNode +}) { + const { stepNum, buttonName, buttonOnClick, overrideButton, children } = props + return ( + <Col className="flex-1"> + {(overrideButton || buttonName) && ( + <> + {overrideButton ?? ( + <Button + size={'md'} + color={'green'} + className="!border-none" + onClick={buttonOnClick} + > + {buttonName} + </Button> + )} + <Spacer h={4} /> + </> + )} + <div> + <p className="inline font-bold">Step {stepNum}. </p> + {children} + </div> + </Col> + ) +} + +function BotConnectButton(props: { + privateUser: PrivateUser | null | undefined +}) { + const { privateUser } = props + const [loading, setLoading] = useState(false) + + const updateBotConnected = (connected: boolean) => async () => { + if (!privateUser) return + const twitchInfo = privateUser.twitchInfo + if (!twitchInfo) return + + const error = connected + ? 'Failed to add bot to your channel' + : 'Failed to remove bot from your channel' + const success = connected + ? 'Added bot to your channel' + : 'Removed bot from your channel' + + setLoading(true) + toast.promise( + updateBotEnabledForUser(privateUser, connected) + .then(() => + updatePrivateUser(privateUser.id, { + twitchInfo: { ...twitchInfo, botEnabled: connected }, + }) + ) + .finally(() => setLoading(false)), + { loading: 'Updating bot settings...', error, success }, + { + loading: { + className: '!max-w-sm', + }, + success: { + className: + '!bg-primary !transition-all !duration-500 !text-white !max-w-sm', + }, + error: { + className: + '!bg-red-400 !transition-all !duration-500 !text-white !max-w-sm', + }, + } + ) + } + + return ( + <> + {privateUser?.twitchInfo?.botEnabled ? ( + <Button + color="red" + onClick={updateBotConnected(false)} + className={clsx(loading && '!btn-disabled', 'border-none')} + > + {loading ? ( + <LoadingIndicator spinnerClassName="!h-5 !w-5 border-white !border-2" /> + ) : ( + 'Remove bot from channel' + )} + </Button> + ) : ( + <Button + color="green" + onClick={updateBotConnected(true)} + className={clsx(loading && '!btn-disabled', 'border-none')} + > + {loading ? ( + <LoadingIndicator spinnerClassName="!h-5 !w-5 border-white !border-2" /> + ) : ( + 'Add bot to your channel' + )} + </Button> + )} + </> + ) +} + +function SetUpBot(props: { + user?: User | null + privateUser?: PrivateUser | null +}) { + const { user, privateUser } = props + const twitchLinked = + privateUser?.twitchInfo?.twitchName && + !privateUser?.twitchInfo?.needsRelinking + ? true + : undefined + const toastTheme = { + className: '!bg-primary !text-white', + icon: <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />, + } + const copyOverlayLink = async () => { + if (!privateUser) return + copyToClipboard(getOverlayURLForUser(privateUser)) + toast.success('Overlay link copied!', toastTheme) + } + const copyDockLink = async () => { + if (!privateUser) return + copyToClipboard(getDockURLForUser(privateUser)) + toast.success('Dock link copied!', toastTheme) + } + + return ( + <> + <Title + text={'Set up the bot for your own stream'} + className={'!mb-4 md:block'} + /> + <Col className="gap-4"> + <img + src="https://raw.githubusercontent.com/PhilBladen/ManifoldTwitchIntegration/master/docs/OBS.png" // TODO: Copy this into the Manifold codebase public folder + className="!-my-2" + ></img> + To add the bot to your stream make sure you have logged in then follow + the steps below. + {!twitchLinked && ( + <ButtonGetStarted + user={user} + privateUser={privateUser} + buttonClass={'!my-0'} + spinnerClass={'!my-0'} + /> + )} + <div className="flex flex-col gap-6 sm:flex-row"> + <BotSetupStep + stepNum={1} + overrideButton={ + twitchLinked && <BotConnectButton privateUser={privateUser} /> + } + > + Use the button above to add the bot to your channel. Then mod it by + typing in your Twitch chat: <b>/mod ManifoldBot</b> + <br /> + If the bot is not modded it will not be able to respond to commands + properly. + </BotSetupStep> + <BotSetupStep + stepNum={2} + buttonName={twitchLinked && 'Overlay link'} + buttonOnClick={copyOverlayLink} + > + Create a new browser source in your streaming software such as OBS. + Paste in the above link and resize it to your liking. We recommend + setting the size to 400x400. + </BotSetupStep> + <BotSetupStep + stepNum={3} + buttonName={twitchLinked && 'Control dock link'} + buttonOnClick={copyDockLink} + > + The bot can be controlled entirely through chat. But we made an easy + to use control panel. Share the link with your mods or embed it into + your OBS as a custom dock. + </BotSetupStep> + </div> + </Col> + </> + ) +} + +export default function TwitchLandingPage() { + useSaveReferral() + useTracking('view twitch landing page') + + const user = useUser() + const privateUser = usePrivateUser() return ( <Page> @@ -62,58 +408,11 @@ export default function TwitchLandingPage() { <div className="px-4 pt-2 md:mt-0 lg:hidden"> <ManifoldLogo /> </div> - <Col className="items-center"> - <Col className="max-w-3xl"> - <Col className="mb-6 rounded-xl sm:m-12 sm:mt-0"> - <Row className="self-center"> - <img height={200} width={200} src="/twitch-logo.png" /> - <img height={200} width={200} src="/flappy-logo.gif" /> - </Row> - <div className="m-4 max-w-[550px] self-center"> - <h1 className="text-3xl sm:text-6xl xl:text-6xl"> - <div className="font-semibold sm:mb-2"> - <span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent"> - Bet - </span>{' '} - on your favorite streams - </div> - </h1> - <Spacer h={6} /> - <div className="mb-4 px-2 "> - Get more out of Twitch with play-money betting markets.{' '} - {!twitchUser && - 'Click the button below to link your Twitch account.'} - <br /> - </div> - </div> - <Spacer h={6} /> - - {twitchUser ? ( - <div className="mt-3 self-center rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 "> - <div className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6"> - <div className="truncate text-sm font-medium text-gray-500"> - Twitch account linked - </div> - <div className="mt-1 text-2xl font-semibold text-gray-900"> - {twitchUser} - </div> - </div> - </div> - ) : isLoading ? ( - <LoadingIndicator spinnerClassName="!w-16 !h-16" /> - ) : ( - <Button - size="2xl" - color="gradient" - className="self-center" - onClick={getStarted} - > - Get started - </Button> - )} - </Col> - </Col> + <Col className="max-w-3xl gap-8 rounded bg-white p-4 text-gray-600 shadow-md sm:mx-auto sm:p-10"> + <TwitchPlaysManifoldMarkets user={user} privateUser={privateUser} /> + <TwitchChatCommands /> + <SetUpBot user={user} privateUser={privateUser} /> </Col> </Page> ) 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 2400 2800" style="enable-background:new 0 0 2400 2800;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:#FFFFFF;} + .st1{fill:#9146FF;} +</style> +<title>Asset 2 + + + + + + + + + + + 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: {