diff --git a/common/add-liquidity.ts b/common/add-liquidity.ts index 254b8936..9271bbbf 100644 --- a/common/add-liquidity.ts +++ b/common/add-liquidity.ts @@ -1,10 +1,9 @@ import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm' import { CPMMContract } from './contract' import { LiquidityProvision } from './liquidity-provision' -import { User } from './user' export const getNewLiquidityProvision = ( - user: User, + userId: string, amount: number, contract: CPMMContract, newLiquidityProvisionId: string @@ -18,7 +17,7 @@ export const getNewLiquidityProvision = ( const newLiquidityProvision: LiquidityProvision = { id: newLiquidityProvisionId, - userId: user.id, + userId: userId, contractId: contract.id, amount, pool: newPool, diff --git a/common/antes.ts b/common/antes.ts index d4e624b1..51aac20f 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -15,6 +15,12 @@ import { Answer } from './answer' export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id +export const UNIQUE_BETTOR_LIQUIDITY_AMOUNT = 20 + +type NormalizedBet = Omit< + T, + 'userAvatarUrl' | 'userName' | 'userUsername' +> export function getCpmmInitialLiquidity( providerId: string, @@ -51,7 +57,7 @@ export function getAnteBets( const { createdTime } = contract - const yesBet: Bet = { + const yesBet: NormalizedBet = { id: yesAnteId, userId: creator.id, contractId: contract.id, @@ -65,7 +71,7 @@ export function getAnteBets( fees: noFees, } - const noBet: Bet = { + const noBet: NormalizedBet = { id: noAnteId, userId: creator.id, contractId: contract.id, @@ -93,7 +99,7 @@ export function getFreeAnswerAnte( const { createdTime } = contract - const anteBet: Bet = { + const anteBet: NormalizedBet = { id: anteBetId, userId: anteBettorId, contractId: contract.id, @@ -123,7 +129,7 @@ export function getMultipleChoiceAntes( const { createdTime } = contract - const bets: Bet[] = answers.map((answer, i) => ({ + const bets: NormalizedBet[] = answers.map((answer, i) => ({ id: betDocIds[i], userId: creator.id, contractId: contract.id, @@ -173,7 +179,7 @@ export function getNumericAnte( range(0, bucketCount).map((_, i) => [i, betAnte]) ) - const anteBet: NumericBet = { + const anteBet: NormalizedBet = { id: newBetId, userId: anteBettorId, contractId: contract.id, diff --git a/common/bet.ts b/common/bet.ts index 8afebcd8..ee869bb5 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -3,6 +3,12 @@ import { Fees } from './fees' export type Bet = { id: string userId: string + + // denormalized for bet lists + userAvatarUrl?: string + userUsername: string + userName: string + contractId: string createdTime: number diff --git a/common/economy.ts b/common/economy.ts index c1449d4f..a412d4de 100644 --- a/common/economy.ts +++ b/common/economy.ts @@ -13,5 +13,5 @@ export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10 export const BETTING_STREAK_BONUS_AMOUNT = econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10 export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 -export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0 +export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7 export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5 diff --git a/common/group.ts b/common/group.ts index 19f3b7b8..871bc821 100644 --- a/common/group.ts +++ b/common/group.ts @@ -12,7 +12,18 @@ export type Group = { aboutPostId?: string chatDisabled?: boolean mostRecentContractAddedTime?: number + cachedLeaderboard?: { + topTraders: { + userId: string + score: number + }[] + topCreators: { + userId: string + score: number + }[] + } } + export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 diff --git a/common/new-bet.ts b/common/new-bet.ts index 7085a4fe..91faf640 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -31,7 +31,10 @@ import { floatingLesserEqual, } from './util/math' -export type CandidateBet = Omit +export type CandidateBet = Omit< + T, + 'id' | 'userId' | 'userAvatarUrl' | 'userName' | 'userUsername' +> export type BetInfo = { newBet: CandidateBet newPool?: { [outcome: string]: number } @@ -322,7 +325,7 @@ export const getNewBinaryDpmBetInfo = ( export const getNewMultiBetInfo = ( outcome: string, amount: number, - contract: FreeResponseContract | MultipleChoiceContract, + contract: FreeResponseContract | MultipleChoiceContract ) => { const { pool, totalShares, totalBets } = contract diff --git a/common/notification.ts b/common/notification.ts index 9ec320fa..affa33cb 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -1,3 +1,6 @@ +import { notification_subscription_types, PrivateUser } from './user' +import { DOMAIN } from './envs/constants' + export type Notification = { id: string userId: string @@ -15,7 +18,7 @@ export type Notification = { sourceUserUsername?: string sourceUserAvatarUrl?: string sourceText?: string - data?: string + data?: { [key: string]: any } sourceContractTitle?: string sourceContractCreatorUsername?: string @@ -51,28 +54,113 @@ export type notification_source_update_types = | 'deleted' | 'closed' +/* Optional - if possible use a keyof notification_subscription_types */ export type notification_reason_types = | 'tagged_user' - | 'on_users_contract' - | 'on_contract_with_users_shares_in' - | 'on_contract_with_users_shares_out' - | 'on_contract_with_users_answer' - | 'on_contract_with_users_comment' - | 'reply_to_users_answer' - | 'reply_to_users_comment' | 'on_new_follow' - | 'you_follow_user' - | 'added_you_to_group' + | 'contract_from_followed_user' | 'you_referred_user' | 'user_joined_to_bet_on_your_market' | 'unique_bettors_on_your_contract' - | 'on_group_you_are_member_of' | 'tip_received' | 'bet_fill' | 'user_joined_from_your_group_invite' | 'challenge_accepted' | 'betting_streak_incremented' | 'loan_income' - | 'you_follow_contract' - | 'liked_your_contract' | 'liked_and_tipped_your_contract' + | 'comment_on_your_contract' + | 'answer_on_your_contract' + | 'comment_on_contract_you_follow' + | 'answer_on_contract_you_follow' + | 'update_on_contract_you_follow' + | 'resolution_on_contract_you_follow' + | 'comment_on_contract_with_users_shares_in' + | 'answer_on_contract_with_users_shares_in' + | 'update_on_contract_with_users_shares_in' + | 'resolution_on_contract_with_users_shares_in' + | 'comment_on_contract_with_users_answer' + | 'update_on_contract_with_users_answer' + | 'resolution_on_contract_with_users_answer' + | 'answer_on_contract_with_users_answer' + | 'comment_on_contract_with_users_comment' + | 'answer_on_contract_with_users_comment' + | 'update_on_contract_with_users_comment' + | 'resolution_on_contract_with_users_comment' + | 'reply_to_users_answer' + | 'reply_to_users_comment' + | 'your_contract_closed' + | 'subsidized_your_market' + +// Adding a new key:value here is optional, you can just use a key of notification_subscription_types +// You might want to add a key:value here if there will be multiple notification reasons that map to the same +// subscription type, i.e. 'comment_on_contract_you_follow' and 'comment_on_contract_with_users_answer' both map to +// 'all_comments_on_watched_markets' subscription type +// TODO: perhaps better would be to map notification_subscription_types to arrays of notification_reason_types +export const notificationReasonToSubscriptionType: Partial< + Record +> = { + you_referred_user: 'referral_bonuses', + user_joined_to_bet_on_your_market: 'referral_bonuses', + tip_received: 'tips_on_your_comments', + bet_fill: 'limit_order_fills', + user_joined_from_your_group_invite: 'referral_bonuses', + challenge_accepted: 'limit_order_fills', + betting_streak_incremented: 'betting_streaks', + liked_and_tipped_your_contract: 'tips_on_your_markets', + comment_on_your_contract: 'all_comments_on_my_markets', + answer_on_your_contract: 'all_answers_on_my_markets', + comment_on_contract_you_follow: 'all_comments_on_watched_markets', + answer_on_contract_you_follow: 'all_answers_on_watched_markets', + update_on_contract_you_follow: 'market_updates_on_watched_markets', + resolution_on_contract_you_follow: 'resolutions_on_watched_markets', + comment_on_contract_with_users_shares_in: + 'all_comments_on_contracts_with_shares_in_on_watched_markets', + answer_on_contract_with_users_shares_in: + 'all_answers_on_contracts_with_shares_in_on_watched_markets', + update_on_contract_with_users_shares_in: + 'market_updates_on_watched_markets_with_shares_in', + resolution_on_contract_with_users_shares_in: + 'resolutions_on_watched_markets_with_shares_in', + comment_on_contract_with_users_answer: 'all_comments_on_watched_markets', + update_on_contract_with_users_answer: 'market_updates_on_watched_markets', + resolution_on_contract_with_users_answer: 'resolutions_on_watched_markets', + answer_on_contract_with_users_answer: 'all_answers_on_watched_markets', + comment_on_contract_with_users_comment: 'all_comments_on_watched_markets', + answer_on_contract_with_users_comment: 'all_answers_on_watched_markets', + update_on_contract_with_users_comment: 'market_updates_on_watched_markets', + resolution_on_contract_with_users_comment: 'resolutions_on_watched_markets', + reply_to_users_answer: 'all_replies_to_my_answers_on_watched_markets', + reply_to_users_comment: 'all_replies_to_my_comments_on_watched_markets', +} + +export const getDestinationsForUser = async ( + privateUser: PrivateUser, + reason: notification_reason_types | keyof notification_subscription_types +) => { + const notificationSettings = privateUser.notificationPreferences + let destinations + let subscriptionType: keyof notification_subscription_types | undefined + if (Object.keys(notificationSettings).includes(reason)) { + subscriptionType = reason as keyof notification_subscription_types + destinations = notificationSettings[subscriptionType] + } else { + const key = reason as notification_reason_types + subscriptionType = notificationReasonToSubscriptionType[key] + destinations = subscriptionType + ? notificationSettings[subscriptionType] + : [] + } + // const unsubscribeEndpoint = getFunctionUrl('unsubscribe') + return { + sendToEmail: destinations.includes('email'), + sendToBrowser: destinations.includes('browser'), + // unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, + urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + } +} + +export type BettingStreakData = { + streak: number + bonusAmount: number +} diff --git a/common/sell-bet.ts b/common/sell-bet.ts index bc8fe596..96636ca0 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -9,7 +9,10 @@ import { CPMMContract, DPMContract } from './contract' import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees' import { sumBy } from 'lodash' -export type CandidateBet = Omit +export type CandidateBet = Omit< + T, + 'id' | 'userId' | 'userAvatarUrl' | 'userName' | 'userUsername' +> export const getSellBetInfo = (bet: Bet, contract: DPMContract) => { const { pool, totalShares, totalBets } = contract diff --git a/common/user.ts b/common/user.ts index f15865cf..7bd89906 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,3 +1,5 @@ +import { filterDefined } from './util/array' + export type User = { id: string createdTime: number @@ -63,9 +65,63 @@ export type PrivateUser = { initialDeviceToken?: string initialIpAddress?: string apiKey?: string - notificationPreferences?: notification_subscribe_types + notificationPreferences: notification_subscription_types + twitchInfo?: { + twitchName: string + controlToken: string + botEnabled?: boolean + } } +export type notification_destination_types = 'email' | 'browser' +export type notification_subscription_types = { + // Watched Markets + all_comments_on_watched_markets: notification_destination_types[] + all_answers_on_watched_markets: notification_destination_types[] + + // Comments + tipped_comments_on_watched_markets: notification_destination_types[] + comments_by_followed_users_on_watched_markets: notification_destination_types[] + all_replies_to_my_comments_on_watched_markets: notification_destination_types[] + all_replies_to_my_answers_on_watched_markets: notification_destination_types[] + all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[] + + // Answers + answers_by_followed_users_on_watched_markets: notification_destination_types[] + answers_by_market_creator_on_watched_markets: notification_destination_types[] + all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[] + + // On users' markets + your_contract_closed: notification_destination_types[] + all_comments_on_my_markets: notification_destination_types[] + all_answers_on_my_markets: notification_destination_types[] + subsidized_your_market: notification_destination_types[] + + // Market updates + resolutions_on_watched_markets: notification_destination_types[] + resolutions_on_watched_markets_with_shares_in: notification_destination_types[] + market_updates_on_watched_markets: notification_destination_types[] + market_updates_on_watched_markets_with_shares_in: notification_destination_types[] + probability_updates_on_watched_markets: notification_destination_types[] + + // Balance Changes + loan_income: notification_destination_types[] + betting_streaks: notification_destination_types[] + referral_bonuses: notification_destination_types[] + unique_bettors_on_your_contract: notification_destination_types[] + tips_on_your_comments: notification_destination_types[] + tips_on_your_markets: notification_destination_types[] + limit_order_fills: notification_destination_types[] + + // General + tagged_user: notification_destination_types[] + on_new_follow: notification_destination_types[] + contract_from_followed_user: notification_destination_types[] + trending_markets: notification_destination_types[] + profit_loss_updates: notification_destination_types[] + onboarding_flow: notification_destination_types[] + thank_you_for_purchases: notification_destination_types[] +} export type notification_subscribe_types = 'all' | 'less' | 'none' export type PortfolioMetrics = { @@ -78,3 +134,122 @@ export type PortfolioMetrics = { export const MANIFOLD_USERNAME = 'ManifoldMarkets' export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' + +export const getDefaultNotificationSettings = ( + userId: string, + privateUser?: PrivateUser, + noEmails?: boolean +) => { + const { + unsubscribedFromCommentEmails, + unsubscribedFromAnswerEmails, + unsubscribedFromResolutionEmails, + unsubscribedFromWeeklyTrendingEmails, + unsubscribedFromGenericEmails, + } = privateUser || {} + + const constructPref = (browserIf: boolean, emailIf: boolean) => { + const browser = browserIf ? 'browser' : undefined + const email = noEmails ? undefined : emailIf ? 'email' : undefined + return filterDefined([browser, email]) as notification_destination_types[] + } + return { + // Watched Markets + all_comments_on_watched_markets: constructPref( + true, + !unsubscribedFromCommentEmails + ), + all_answers_on_watched_markets: constructPref( + true, + !unsubscribedFromAnswerEmails + ), + + // Comments + tips_on_your_comments: constructPref(true, !unsubscribedFromCommentEmails), + comments_by_followed_users_on_watched_markets: constructPref(true, false), + all_replies_to_my_comments_on_watched_markets: constructPref( + true, + !unsubscribedFromCommentEmails + ), + all_replies_to_my_answers_on_watched_markets: constructPref( + true, + !unsubscribedFromCommentEmails + ), + all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref( + true, + !unsubscribedFromCommentEmails + ), + + // Answers + answers_by_followed_users_on_watched_markets: constructPref( + true, + !unsubscribedFromAnswerEmails + ), + answers_by_market_creator_on_watched_markets: constructPref( + true, + !unsubscribedFromAnswerEmails + ), + all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref( + true, + !unsubscribedFromAnswerEmails + ), + + // On users' markets + your_contract_closed: constructPref( + true, + !unsubscribedFromResolutionEmails + ), // High priority + all_comments_on_my_markets: constructPref( + true, + !unsubscribedFromCommentEmails + ), + all_answers_on_my_markets: constructPref( + true, + !unsubscribedFromAnswerEmails + ), + subsidized_your_market: constructPref(true, true), + + // Market updates + resolutions_on_watched_markets: constructPref( + true, + !unsubscribedFromResolutionEmails + ), + market_updates_on_watched_markets: constructPref(true, false), + market_updates_on_watched_markets_with_shares_in: constructPref( + true, + false + ), + resolutions_on_watched_markets_with_shares_in: constructPref( + true, + !unsubscribedFromResolutionEmails + ), + + //Balance Changes + loan_income: constructPref(true, false), + betting_streaks: constructPref(true, false), + referral_bonuses: constructPref(true, true), + unique_bettors_on_your_contract: constructPref(true, false), + tipped_comments_on_watched_markets: constructPref( + true, + !unsubscribedFromCommentEmails + ), + tips_on_your_markets: constructPref(true, true), + limit_order_fills: constructPref(true, false), + + // General + tagged_user: constructPref(true, true), + on_new_follow: constructPref(true, true), + contract_from_followed_user: constructPref(true, true), + trending_markets: constructPref( + false, + !unsubscribedFromWeeklyTrendingEmails + ), + profit_loss_updates: constructPref(false, true), + probability_updates_on_watched_markets: constructPref(true, false), + thank_you_for_purchases: constructPref( + false, + !unsubscribedFromGenericEmails + ), + onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails), + } as notification_subscription_types +} diff --git a/common/util/parse.ts b/common/util/parse.ts index 4fac3225..0bbd5cd9 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -23,8 +23,15 @@ import { Link } from '@tiptap/extension-link' import { Mention } from '@tiptap/extension-mention' import Iframe from './tiptap-iframe' import TiptapTweet from './tiptap-tweet-type' +import { find } from 'linkifyjs' import { uniq } from 'lodash' +/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ +export function getUrl(text: string) { + const results = find(text, 'url') + return results.length ? results[0].href : null +} + export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi const matches = (text.match(regex) || []).map((match) => diff --git a/firebase.json b/firebase.json index 25f9b61f..5dea5ade 100644 --- a/firebase.json +++ b/firebase.json @@ -2,10 +2,30 @@ "functions": { "predeploy": "cd functions && yarn build", "runtime": "nodejs16", - "source": "functions/dist" + "source": "functions/dist", + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log" + ] }, "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" + }, + "emulators": { + "functions": { + "port": 5001 + }, + "firestore": { + "port": 8080 + }, + "pubsub": { + "port": 8085 + }, + "ui": { + "enabled": true + } } } diff --git a/firestore.rules b/firestore.rules index 9a72e454..6f2ea90a 100644 --- a/firestore.rules +++ b/firestore.rules @@ -77,7 +77,7 @@ service cloud.firestore { allow read: if userId == request.auth.uid || isAdmin(); allow update: if (userId == request.auth.uid || isAdmin()) && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]); + .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'unsubscribedFromWeeklyTrendingEmails', 'notificationPreferences', 'twitchInfo']); } match /private-users/{userId}/views/{viewId} { @@ -170,7 +170,7 @@ service cloud.firestore { allow read; } - match /groups/{groupId} { + match /groups/{groupId} { allow read; allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) @@ -184,7 +184,7 @@ service cloud.firestore { match /groupMembers/{memberId}{ allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin); - allow delete: if request.auth.uid == resource.data.userId; + allow delete: if request.auth.uid == resource.data.userId; } function isGroupMember() { diff --git a/functions/.gitignore b/functions/.gitignore index 58f30dcb..bd3d0c29 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -17,4 +17,5 @@ package-lock.json ui-debug.log firebase-debug.log firestore-debug.log +pubsub-debug.log firestore_export/ diff --git a/functions/src/add-liquidity.ts b/functions/src/add-liquidity.ts index 6746486e..e6090111 100644 --- a/functions/src/add-liquidity.ts +++ b/functions/src/add-liquidity.ts @@ -1,11 +1,16 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { Contract } from '../../common/contract' +import { Contract, CPMMContract } from '../../common/contract' import { User } from '../../common/user' import { removeUndefinedProps } from '../../common/util/object' import { getNewLiquidityProvision } from '../../common/add-liquidity' import { APIError, newEndpoint, validate } from './api' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' +import { isProd } from './utils' const bodySchema = z.object({ contractId: z.string(), @@ -47,7 +52,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => { const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = getNewLiquidityProvision( - user, + user.id, amount, contract, newLiquidityProvisionDoc.id @@ -88,3 +93,41 @@ export const addliquidity = newEndpoint({}, async (req, auth) => { }) const firestore = admin.firestore() + +export const addHouseLiquidity = (contract: CPMMContract, amount: number) => { + return firestore.runTransaction(async (transaction) => { + const newLiquidityProvisionDoc = firestore + .collection(`contracts/${contract.id}/liquidity`) + .doc() + + const providerId = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + + const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = + getNewLiquidityProvision( + providerId, + amount, + contract, + newLiquidityProvisionDoc.id + ) + + if (newP !== undefined && !isFinite(newP)) { + throw new APIError( + 500, + 'Liquidity injection rejected due to overflow error.' + ) + } + + transaction.update( + firestore.doc(`contracts/${contract.id}`), + removeUndefinedProps({ + pool: newPool, + p: newP, + totalLiquidity: newTotalLiquidity, + }) + ) + + transaction.create(newLiquidityProvisionDoc, newLiquidityProvision) + }) +} diff --git a/functions/src/change-user-info.ts b/functions/src/change-user-info.ts index ca66f1ba..53908741 100644 --- a/functions/src/change-user-info.ts +++ b/functions/src/change-user-info.ts @@ -2,6 +2,7 @@ import * as admin from 'firebase-admin' import { z } from 'zod' import { getUser } from './utils' +import { Bet } from '../../common/bet' import { Contract } from '../../common/contract' import { Comment } from '../../common/comment' import { User } from '../../common/user' @@ -68,10 +69,21 @@ export const changeUser = async ( .get() const answerUpdate: Partial = removeUndefinedProps(update) + const betsSnap = await firestore + .collectionGroup('bets') + .where('userId', '==', user.id) + .get() + const betsUpdate: Partial = removeUndefinedProps({ + userName: update.name, + userUsername: update.username, + userAvatarUrl: update.avatarUrl, + }) + const bulkWriter = firestore.bulkWriter() commentSnap.docs.forEach((d) => bulkWriter.update(d.ref, commentUpdate)) answerSnap.docs.forEach((d) => bulkWriter.update(d.ref, answerUpdate)) contracts.docs.forEach((d) => bulkWriter.update(d.ref, contractUpdate)) + betsSnap.docs.forEach((d) => bulkWriter.update(d.ref, betsUpdate)) await bulkWriter.flush() console.log('Done writing!') diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index 0b8b4e7a..cc05d817 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -5,8 +5,7 @@ import { Contract } from '../../common/contract' import { User } from '../../common/user' import { getNewMultiBetInfo } from '../../common/new-bet' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' -import { getContract, getValues } from './utils' -import { sendNewAnswerEmail } from './emails' +import { getValues } from './utils' import { APIError, newEndpoint, validate } from './api' const bodySchema = z.object({ @@ -97,10 +96,6 @@ export const createanswer = newEndpoint(opts, async (req, auth) => { return answer }) - const contract = await getContract(contractId) - - if (answer && contract) await sendNewAnswerEmail(answer, contract) - return answer }) diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index fc64aeff..9d00bb0b 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -10,7 +10,7 @@ import { MAX_GROUP_NAME_LENGTH, MAX_ID_LENGTH, } from '../../common/group' -import { APIError, newEndpoint, validate } from '../../functions/src/api' +import { APIError, newEndpoint, validate } from './api' import { z } from 'zod' const bodySchema = z.object({ diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 131d6e85..34a8f218 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -1,34 +1,42 @@ import * as admin from 'firebase-admin' import { + BettingStreakData, + getDestinationsForUser, Notification, notification_reason_types, - notification_source_update_types, - notification_source_types, } from '../../common/notification' import { User } from '../../common/user' import { Contract } from '../../common/contract' -import { getValues, log } from './utils' +import { getPrivateUser, getValues } from './utils' import { Comment } from '../../common/comment' -import { uniq } from 'lodash' +import { groupBy, uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' -import { Group, GROUP_CHAT_SLUG } from '../../common/group' +import { Group } from '../../common/group' import { Challenge } from '../../common/challenge' -import { richTextToString } from '../../common/util/parse' import { Like } from '../../common/like' +import { + sendMarketCloseEmail, + sendMarketResolutionEmail, + sendNewAnswerEmail, + sendNewCommentEmail, + sendNewFollowedMarketEmail, + sendNewUniqueBettorsEmail, +} from './emails' +import { filterDefined } from '../../common/util/array' const firestore = admin.firestore() -type user_to_reason_texts = { +type recipients_to_reason_texts = { [userId: string]: { reason: notification_reason_types } } export const createNotification = async ( sourceId: string, - sourceType: notification_source_types, - sourceUpdateType: notification_source_update_types, + sourceType: 'contract' | 'liquidity' | 'follow', + sourceUpdateType: 'closed' | 'created', sourceUser: User, idempotencyKey: string, sourceText: string, @@ -41,9 +49,9 @@ export const createNotification = async ( ) => { const { contract: sourceContract, recipients, slug, title } = miscData ?? {} - const shouldGetNotification = ( + const shouldReceiveNotification = ( userId: string, - userToReasonTexts: user_to_reason_texts + userToReasonTexts: recipients_to_reason_texts ) => { return ( sourceUser.id != userId && @@ -51,18 +59,25 @@ export const createNotification = async ( ) } - const createUsersNotifications = async ( - userToReasonTexts: user_to_reason_texts + const sendNotificationsIfSettingsPermit = async ( + userToReasonTexts: recipients_to_reason_texts ) => { - await Promise.all( - Object.keys(userToReasonTexts).map(async (userId) => { + for (const userId in userToReasonTexts) { + const { reason } = userToReasonTexts[userId] + const privateUser = await getPrivateUser(userId) + if (!privateUser) continue + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + privateUser, + reason + ) + if (sendToBrowser) { const notificationRef = firestore .collection(`/users/${userId}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId, - reason: userToReasonTexts[userId].reason, + reason, createdTime: Date.now(), isSeen: false, sourceId, @@ -80,212 +95,232 @@ export const createNotification = async ( sourceTitle: title ? title : sourceContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) - }) - ) - } - - const notifyUsersFollowers = async ( - userToReasonTexts: user_to_reason_texts - ) => { - const followers = await firestore - .collectionGroup('follows') - .where('userId', '==', sourceUser.id) - .get() - - followers.docs.forEach((doc) => { - const followerUserId = doc.ref.parent.parent?.id - if ( - followerUserId && - shouldGetNotification(followerUserId, userToReasonTexts) - ) { - userToReasonTexts[followerUserId] = { - reason: 'you_follow_user', - } } - }) - } - const notifyFollowedUser = ( - userToReasonTexts: user_to_reason_texts, - followedUserId: string - ) => { - if (shouldGetNotification(followedUserId, userToReasonTexts)) - userToReasonTexts[followedUserId] = { - reason: 'on_new_follow', + if (!sendToEmail) continue + + if (reason === 'your_contract_closed' && privateUser && sourceContract) { + // TODO: include number and names of bettors waiting for creator to resolve their market + await sendMarketCloseEmail( + reason, + sourceUser, + privateUser, + sourceContract + ) + } else if (reason === 'subsidized_your_market') { + // TODO: send email to creator of market that was subsidized + } else if (reason === 'on_new_follow') { + // TODO: send email to user who was followed } + } } - const notifyTaggedUsers = ( - userToReasonTexts: user_to_reason_texts, - userIds: (string | undefined)[] - ) => { - userIds.forEach((id) => { - if (id && shouldGetNotification(id, userToReasonTexts)) - userToReasonTexts[id] = { - reason: 'tagged_user', - } - }) - } - - const notifyContractCreator = async ( - userToReasonTexts: user_to_reason_texts, - sourceContract: Contract, - options?: { force: boolean } - ) => { - if ( - options?.force || - shouldGetNotification(sourceContract.creatorId, userToReasonTexts) - ) - userToReasonTexts[sourceContract.creatorId] = { - reason: 'on_users_contract', - } - } - - const notifyUserAddedToGroup = ( - userToReasonTexts: user_to_reason_texts, - relatedUserId: string - ) => { - if (shouldGetNotification(relatedUserId, userToReasonTexts)) - userToReasonTexts[relatedUserId] = { - reason: 'added_you_to_group', - } - } - - const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. + const userToReasonTexts: recipients_to_reason_texts = {} if (sourceType === 'follow' && recipients?.[0]) { - notifyFollowedUser(userToReasonTexts, recipients[0]) - } else if ( - sourceType === 'group' && - sourceUpdateType === 'created' && - recipients - ) { - recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r)) - } else if ( - sourceType === 'contract' && - sourceUpdateType === 'created' && - sourceContract - ) { - await notifyUsersFollowers(userToReasonTexts) - notifyTaggedUsers(userToReasonTexts, recipients ?? []) + if (shouldReceiveNotification(recipients[0], userToReasonTexts)) + userToReasonTexts[recipients[0]] = { + reason: 'on_new_follow', + } + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'contract' && sourceUpdateType === 'closed' && sourceContract ) { - await notifyContractCreator(userToReasonTexts, sourceContract, { - force: true, - }) + userToReasonTexts[sourceContract.creatorId] = { + reason: 'your_contract_closed', + } + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'liquidity' && sourceUpdateType === 'created' && sourceContract ) { - await notifyContractCreator(userToReasonTexts, sourceContract) + if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts)) + userToReasonTexts[sourceContract.creatorId] = { + reason: 'subsidized_your_market', + } + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } +} - await createUsersNotifications(userToReasonTexts) +export type replied_users_info = { + [key: string]: { + repliedToType: 'comment' | 'answer' + repliedToAnswerText: string | undefined + repliedToId: string | undefined + bet: Bet | undefined + } } export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceId: string, - sourceType: notification_source_types, - sourceUpdateType: notification_source_update_types, + sourceType: 'comment' | 'answer' | 'contract', + sourceUpdateType: 'created' | 'updated' | 'resolved', sourceUser: User, idempotencyKey: string, sourceText: string, sourceContract: Contract, miscData?: { - relatedSourceType?: notification_source_types - repliedUserId?: string - taggedUserIds?: string[] + repliedUsersInfo: replied_users_info + taggedUserIds: string[] + }, + resolutionData?: { + bets: Bet[] + userInvestments: { [userId: string]: number } + userPayouts: { [userId: string]: number } + creator: User + creatorPayout: number + contract: Contract + outcome: string + resolutionProbability?: number + resolutions?: { [outcome: string]: number } } ) => { - const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {} + const { repliedUsersInfo, taggedUserIds } = miscData ?? {} - const createUsersNotifications = async ( - userToReasonTexts: user_to_reason_texts - ) => { - await Promise.all( - Object.keys(userToReasonTexts).map(async (userId) => { - const notificationRef = firestore - .collection(`/users/${userId}/notifications`) - .doc(idempotencyKey) - const notification: Notification = { - id: idempotencyKey, - userId, - reason: userToReasonTexts[userId].reason, - createdTime: Date.now(), - isSeen: false, - sourceId, - sourceType, - sourceUpdateType, - sourceContractId: sourceContract.id, - sourceUserName: sourceUser.name, - sourceUserUsername: sourceUser.username, - sourceUserAvatarUrl: sourceUser.avatarUrl, - sourceText, - sourceContractCreatorUsername: sourceContract.creatorUsername, - sourceContractTitle: sourceContract.question, - sourceContractSlug: sourceContract.slug, - sourceSlug: sourceContract.slug, - sourceTitle: sourceContract.question, - } - await notificationRef.set(removeUndefinedProps(notification)) - }) - ) - } + const browserRecipientIdsList: string[] = [] + const emailRecipientIdsList: string[] = [] - // get contract follower documents and check here if they're a follower const contractFollowersSnap = await firestore .collection(`contracts/${sourceContract.id}/follows`) .get() const contractFollowersIds = contractFollowersSnap.docs.map( (doc) => doc.data().id ) - log('contractFollowerIds', contractFollowersIds) + + const createBrowserNotification = async ( + userId: string, + reason: notification_reason_types + ) => { + const notificationRef = firestore + .collection(`/users/${userId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId, + reason, + createdTime: Date.now(), + isSeen: false, + sourceId, + sourceType, + sourceUpdateType, + sourceContractId: sourceContract.id, + sourceUserName: sourceUser.name, + sourceUserUsername: sourceUser.username, + sourceUserAvatarUrl: sourceUser.avatarUrl, + sourceText, + sourceContractCreatorUsername: sourceContract.creatorUsername, + sourceContractTitle: sourceContract.question, + sourceContractSlug: sourceContract.slug, + sourceSlug: sourceContract.slug, + sourceTitle: sourceContract.question, + } + return await notificationRef.set(removeUndefinedProps(notification)) + } const stillFollowingContract = (userId: string) => { return contractFollowersIds.includes(userId) } - const shouldGetNotification = ( + const sendNotificationsIfSettingsPermit = async ( userId: string, - userToReasonTexts: user_to_reason_texts + reason: notification_reason_types ) => { - return ( - sourceUser.id != userId && - !Object.keys(userToReasonTexts).includes(userId) + if ( + !stillFollowingContract(sourceContract.creatorId) || + sourceUser.id == userId + ) + return + const privateUser = await getPrivateUser(userId) + if (!privateUser) return + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + privateUser, + reason ) - } - const notifyContractFollowers = async ( - userToReasonTexts: user_to_reason_texts - ) => { - for (const userId of contractFollowersIds) { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'you_follow_contract', - } + // Browser notifications + if (sendToBrowser && !browserRecipientIdsList.includes(userId)) { + await createBrowserNotification(userId, reason) + browserRecipientIdsList.push(userId) + } + + // Emails notifications + if (!sendToEmail || emailRecipientIdsList.includes(userId)) return + if (sourceType === 'comment') { + const { repliedToType, repliedToAnswerText, repliedToId, bet } = + repliedUsersInfo?.[userId] ?? {} + // TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment + await sendNewCommentEmail( + reason, + privateUser, + sourceUser, + sourceContract, + sourceText, + sourceId, + bet, + repliedToAnswerText, + repliedToType === 'answer' ? repliedToId : undefined + ) + emailRecipientIdsList.push(userId) + } else if (sourceType === 'answer') { + await sendNewAnswerEmail( + reason, + privateUser, + sourceUser.name, + sourceText, + sourceContract, + sourceUser.avatarUrl + ) + emailRecipientIdsList.push(userId) + } else if ( + sourceType === 'contract' && + sourceUpdateType === 'resolved' && + resolutionData + ) { + await sendMarketResolutionEmail( + reason, + privateUser, + resolutionData.userInvestments[userId] ?? 0, + resolutionData.userPayouts[userId] ?? 0, + sourceUser, + resolutionData.creatorPayout, + sourceContract, + resolutionData.outcome, + resolutionData.resolutionProbability, + resolutionData.resolutions + ) + emailRecipientIdsList.push(userId) } } - const notifyContractCreator = async ( - userToReasonTexts: user_to_reason_texts - ) => { - if ( - shouldGetNotification(sourceContract.creatorId, userToReasonTexts) && - stillFollowingContract(sourceContract.creatorId) - ) - userToReasonTexts[sourceContract.creatorId] = { - reason: 'on_users_contract', - } + const notifyContractFollowers = async () => { + for (const userId of contractFollowersIds) { + await sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_you_follow' + : sourceType === 'comment' + ? 'comment_on_contract_you_follow' + : sourceUpdateType === 'updated' + ? 'update_on_contract_you_follow' + : 'resolution_on_contract_you_follow' + ) + } } - const notifyOtherAnswerersOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyContractCreator = async () => { + await sendNotificationsIfSettingsPermit( + sourceContract.creatorId, + sourceType === 'comment' + ? 'comment_on_your_contract' + : 'answer_on_your_contract' + ) + } + + const notifyOtherAnswerersOnContract = async () => { const answers = await getValues( firestore .collection('contracts') @@ -293,20 +328,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( .collection('answers') ) const recipientUserIds = uniq(answers.map((answer) => answer.userId)) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_answer' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_answer' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_answer' + : 'resolution_on_contract_with_users_answer' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_answer', - } - }) + ) } - const notifyOtherCommentersOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyOtherCommentersOnContract = async () => { const comments = await getValues( firestore .collection('contracts') @@ -314,20 +352,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( .collection('comments') ) const recipientUserIds = uniq(comments.map((comment) => comment.userId)) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_comment' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_comment' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_comment' + : 'resolution_on_contract_with_users_comment' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_comment', - } - }) + ) } - const notifyBettorsOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyBettorsOnContract = async () => { const betsSnap = await firestore .collection(`contracts/${sourceContract.id}/bets`) .get() @@ -343,88 +384,77 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( ) } ) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_shares_in' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_shares_in' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_shares_in' + : 'resolution_on_contract_with_users_shares_in' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - }) + ) } - const notifyRepliedUser = ( - userToReasonTexts: user_to_reason_texts, - relatedUserId: string, - relatedSourceType: notification_source_types - ) => { - if ( - shouldGetNotification(relatedUserId, userToReasonTexts) && - stillFollowingContract(relatedUserId) - ) { - if (relatedSourceType === 'comment') { - userToReasonTexts[relatedUserId] = { - reason: 'reply_to_users_comment', - } - } else if (relatedSourceType === 'answer') { - userToReasonTexts[relatedUserId] = { - reason: 'reply_to_users_answer', - } - } - } + const notifyRepliedUser = async () => { + if (sourceType === 'comment' && repliedUsersInfo) + await Promise.all( + Object.keys(repliedUsersInfo).map((userId) => + sendNotificationsIfSettingsPermit( + userId, + repliedUsersInfo[userId].repliedToType === 'answer' + ? 'reply_to_users_answer' + : 'reply_to_users_comment' + ) + ) + ) } - const notifyTaggedUsers = ( - userToReasonTexts: user_to_reason_texts, - userIds: (string | undefined)[] - ) => { - userIds.forEach((id) => { - console.log('tagged user: ', id) - // Allowing non-following users to get tagged - if (id && shouldGetNotification(id, userToReasonTexts)) - userToReasonTexts[id] = { - reason: 'tagged_user', - } - }) + const notifyTaggedUsers = async () => { + if (sourceType === 'comment' && taggedUserIds && taggedUserIds.length > 0) + await Promise.all( + taggedUserIds.map((userId) => + sendNotificationsIfSettingsPermit(userId, 'tagged_user') + ) + ) } - const notifyLiquidityProviders = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyLiquidityProviders = async () => { const liquidityProviders = await firestore .collection(`contracts/${sourceContract.id}/liquidity`) .get() const liquidityProvidersIds = uniq( liquidityProviders.docs.map((doc) => doc.data().userId) ) - liquidityProvidersIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) - ) { - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - } - }) + await Promise.all( + liquidityProvidersIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_shares_in' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_shares_in' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_shares_in' + : 'resolution_on_contract_with_users_shares_in' + ) + ) + ) } - const userToReasonTexts: user_to_reason_texts = {} - if (sourceType === 'comment') { - if (repliedUserId && relatedSourceType) - notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType) - if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? []) - } - await notifyContractCreator(userToReasonTexts) - await notifyOtherAnswerersOnContract(userToReasonTexts) - await notifyLiquidityProviders(userToReasonTexts) - await notifyBettorsOnContract(userToReasonTexts) - await notifyOtherCommentersOnContract(userToReasonTexts) - // if they weren't added previously, add them now - await notifyContractFollowers(userToReasonTexts) - - await createUsersNotifications(userToReasonTexts) + await notifyRepliedUser() + await notifyTaggedUsers() + await notifyContractCreator() + await notifyOtherAnswerersOnContract() + await notifyLiquidityProviders() + await notifyBettorsOnContract() + await notifyOtherCommentersOnContract() + // if they weren't notified previously, notify them now + await notifyContractFollowers() } export const createTipNotification = async ( @@ -436,8 +466,15 @@ export const createTipNotification = async ( contract?: Contract, group?: Group ) => { - const slug = group ? group.slug + `#${commentId}` : commentId + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'tip_received' + ) + if (!sendToBrowser) return + const slug = group ? group.slug + `#${commentId}` : commentId const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -461,6 +498,9 @@ export const createTipNotification = async ( sourceTitle: group?.name, } return await notificationRef.set(removeUndefinedProps(notification)) + + // TODO: send notification to users that are watching the contract and want highly tipped comments only + // maybe TODO: send email notification to bet creator } export const createBetFillNotification = async ( @@ -471,6 +511,14 @@ export const createBetFillNotification = async ( contract: Contract, idempotencyKey: string ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'bet_fill' + ) + if (!sendToBrowser) return + const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id) const fillAmount = fill?.amount ?? 0 @@ -496,38 +544,8 @@ export const createBetFillNotification = async ( sourceContractId: contract.id, } return await notificationRef.set(removeUndefinedProps(notification)) -} -export const createGroupCommentNotification = async ( - fromUser: User, - toUserId: string, - comment: Comment, - group: Group, - idempotencyKey: string -) => { - if (toUserId === fromUser.id) return - const notificationRef = firestore - .collection(`/users/${toUserId}/notifications`) - .doc(idempotencyKey) - const sourceSlug = `/group/${group.slug}/${GROUP_CHAT_SLUG}` - const notification: Notification = { - id: idempotencyKey, - userId: toUserId, - reason: 'on_group_you_are_member_of', - createdTime: Date.now(), - isSeen: false, - sourceId: comment.id, - sourceType: 'comment', - sourceUpdateType: 'created', - sourceUserName: fromUser.name, - sourceUserUsername: fromUser.username, - sourceUserAvatarUrl: fromUser.avatarUrl, - sourceText: richTextToString(comment.content), - sourceSlug, - sourceTitle: `${group.name}`, - isSeenOnHref: sourceSlug, - } - await notificationRef.set(removeUndefinedProps(notification)) + // maybe TODO: send email notification to bet creator } export const createReferralNotification = async ( @@ -538,6 +556,14 @@ export const createReferralNotification = async ( referredByContract?: Contract, referredByGroup?: Group ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'you_referred_user' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -575,6 +601,8 @@ export const createReferralNotification = async ( : referredByContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) + + // TODO send email notification } export const createLoanIncomeNotification = async ( @@ -582,6 +610,14 @@ export const createLoanIncomeNotification = async ( idempotencyKey: string, income: number ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'loan_income' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -612,6 +648,14 @@ export const createChallengeAcceptedNotification = async ( acceptedAmount: number, contract: Contract ) => { + const privateUser = await getPrivateUser(challengeCreator.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'challenge_accepted' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${challengeCreator.id}/notifications`) .doc() @@ -643,8 +687,17 @@ export const createBettingStreakBonusNotification = async ( bet: Bet, contract: Contract, amount: number, + streak: number, idempotencyKey: string ) => { + const privateUser = await getPrivateUser(user.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'betting_streak_incremented' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${user.id}/notifications`) .doc(idempotencyKey) @@ -668,6 +721,10 @@ export const createBettingStreakBonusNotification = async ( sourceContractId: contract.id, sourceContractTitle: contract.question, sourceContractCreatorUsername: contract.creatorUsername, + data: { + streak: streak, + bonusAmount: amount, + } as BettingStreakData, } return await notificationRef.set(removeUndefinedProps(notification)) } @@ -680,13 +737,24 @@ export const createLikeNotification = async ( contract: Contract, tip?: TipTxn ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'liked_and_tipped_your_contract' + ) + if (!sendToBrowser) return + + // not handling just likes, must include tip + if (!tip) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId: toUser.id, - reason: tip ? 'liked_and_tipped_your_contract' : 'liked_your_contract', + reason: 'liked_and_tipped_your_contract', createdTime: Date.now(), isSeen: false, sourceId: like.id, @@ -703,20 +771,8 @@ export const createLikeNotification = async ( sourceTitle: contract.question, } return await notificationRef.set(removeUndefinedProps(notification)) -} -export async function filterUserIdsForOnlyFollowerIds( - userIds: string[], - contractId: string -) { - // get contract follower documents and check here if they're a follower - const contractFollowersSnap = await firestore - .collection(`contracts/${contractId}/follows`) - .get() - const contractFollowersIds = contractFollowersSnap.docs.map( - (doc) => doc.data().id - ) - return userIds.filter((id) => contractFollowersIds.includes(id)) + // TODO send email notification } export const createUniqueBettorBonusNotification = async ( @@ -725,31 +781,158 @@ export const createUniqueBettorBonusNotification = async ( txnId: string, contract: Contract, amount: number, + uniqueBettorIds: string[], idempotencyKey: string ) => { - const notificationRef = firestore - .collection(`/users/${contractCreatorId}/notifications`) - .doc(idempotencyKey) - const notification: Notification = { - id: idempotencyKey, - userId: contractCreatorId, - reason: 'unique_bettors_on_your_contract', - createdTime: Date.now(), - isSeen: false, - sourceId: txnId, - sourceType: 'bonus', - sourceUpdateType: 'created', - sourceUserName: bettor.name, - sourceUserUsername: bettor.username, - sourceUserAvatarUrl: bettor.avatarUrl, - sourceText: amount.toString(), - sourceSlug: contract.slug, - sourceTitle: contract.question, - // Perhaps not necessary, but just in case - sourceContractSlug: contract.slug, - sourceContractId: contract.id, - sourceContractTitle: contract.question, - sourceContractCreatorUsername: contract.creatorUsername, + const privateUser = await getPrivateUser(contractCreatorId) + if (!privateUser) return + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + privateUser, + 'unique_bettors_on_your_contract' + ) + if (sendToBrowser) { + const notificationRef = firestore + .collection(`/users/${contractCreatorId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: contractCreatorId, + reason: 'unique_bettors_on_your_contract', + createdTime: Date.now(), + isSeen: false, + sourceId: txnId, + sourceType: 'bonus', + sourceUpdateType: 'created', + sourceUserName: bettor.name, + sourceUserUsername: bettor.username, + sourceUserAvatarUrl: bettor.avatarUrl, + sourceText: amount.toString(), + sourceSlug: contract.slug, + sourceTitle: contract.question, + // Perhaps not necessary, but just in case + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + await notificationRef.set(removeUndefinedProps(notification)) + } + + if (!sendToEmail) return + const uniqueBettorsExcludingCreator = uniqueBettorIds.filter( + (id) => id !== contractCreatorId + ) + // only send on 1st and 6th bettor + if ( + uniqueBettorsExcludingCreator.length !== 1 && + uniqueBettorsExcludingCreator.length !== 6 + ) + return + const totalNewBettorsToReport = + uniqueBettorsExcludingCreator.length === 1 ? 1 : 5 + + const mostRecentUniqueBettors = await getValues( + firestore + .collection('users') + .where( + 'id', + 'in', + uniqueBettorsExcludingCreator.slice( + uniqueBettorsExcludingCreator.length - totalNewBettorsToReport, + uniqueBettorsExcludingCreator.length + ) + ) + ) + + const bets = await getValues( + firestore.collection('contracts').doc(contract.id).collection('bets') + ) + // group bets by bettors + const bettorsToTheirBets = groupBy(bets, (bet) => bet.userId) + await sendNewUniqueBettorsEmail( + 'unique_bettors_on_your_contract', + contractCreatorId, + privateUser, + contract, + uniqueBettorsExcludingCreator.length, + mostRecentUniqueBettors, + bettorsToTheirBets, + Math.round(amount * totalNewBettorsToReport) + ) +} + +export const createNewContractNotification = async ( + contractCreator: User, + contract: Contract, + idempotencyKey: string, + text: string, + mentionedUserIds: string[] +) => { + if (contract.visibility !== 'public') return + + const sendNotificationsIfSettingsAllow = async ( + userId: string, + reason: notification_reason_types + ) => { + const privateUser = await getPrivateUser(userId) + if (!privateUser) return + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + privateUser, + reason + ) + if (sendToBrowser) { + const notificationRef = firestore + .collection(`/users/${userId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: userId, + reason, + createdTime: Date.now(), + isSeen: false, + sourceId: contract.id, + sourceType: 'contract', + sourceUpdateType: 'created', + sourceUserName: contractCreator.name, + sourceUserUsername: contractCreator.username, + sourceUserAvatarUrl: contractCreator.avatarUrl, + sourceText: text, + sourceSlug: contract.slug, + sourceTitle: contract.question, + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + await notificationRef.set(removeUndefinedProps(notification)) + } + if (!sendToEmail) return + if (reason === 'contract_from_followed_user') + await sendNewFollowedMarketEmail(reason, userId, privateUser, contract) + } + const followersSnapshot = await firestore + .collectionGroup('follows') + .where('userId', '==', contractCreator.id) + .get() + + const followerUserIds = filterDefined( + followersSnapshot.docs.map((doc) => { + const followerUserId = doc.ref.parent.parent?.id + return followerUserId && followerUserId != contractCreator.id + ? followerUserId + : undefined + }) + ) + + // As it is coded now, the tag notification usurps the new contract notification + // It'd be easy to append the reason to the eventId if desired + for (const followerUserId of followerUserIds) { + await sendNotificationsIfSettingsAllow( + followerUserId, + 'contract_from_followed_user' + ) + } + for (const mentionedUserId of mentionedUserIds) { + await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user') } - return await notificationRef.set(removeUndefinedProps(notification)) } diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index eabe0fd0..ab5f014a 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,7 +1,11 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { PrivateUser, User } from '../../common/user' +import { + getDefaultNotificationSettings, + PrivateUser, + User, +} from '../../common/user' import { getUser, getUserByUsername, getValues } from './utils' import { randomString } from '../../common/util/random' import { @@ -79,6 +83,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { email, initialIpAddress: req.ip, initialDeviceToken: deviceToken, + notificationPreferences: getDefaultNotificationSettings(auth.uid), } await firestore.collection('private-users').doc(auth.uid).create(privateUser) diff --git a/functions/src/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html index 6c75f026..c8f6a171 100644 --- a/functions/src/email-templates/500-mana.html +++ b/functions/src/email-templates/500-mana.html @@ -284,9 +284,12 @@ style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
-

This e-mail has been sent to {{name}}, click here to unsubscribe.

+

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

diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html index df215bdc..c73f7458 100644 --- a/functions/src/email-templates/creating-market.html +++ b/functions/src/email-templates/creating-market.html @@ -491,10 +491,10 @@ ">

This e-mail has been sent to {{name}}, - click here to unsubscribe. + " target="_blank">click here to manage your notifications.

diff --git a/functions/src/email-templates/interesting-markets.html b/functions/src/email-templates/interesting-markets.html index d00b227e..7c3e653d 100644 --- a/functions/src/email-templates/interesting-markets.html +++ b/functions/src/email-templates/interesting-markets.html @@ -440,11 +440,10 @@

This e-mail has been sent to {{name}}, - click here to unsubscribe from future recommended markets. + " target="_blank">click here to manage your notifications.

diff --git a/functions/src/email-templates/market-answer-comment.html b/functions/src/email-templates/market-answer-comment.html index 4e1a2bfa..a19aa7c3 100644 --- a/functions/src/email-templates/market-answer-comment.html +++ b/functions/src/email-templates/market-answer-comment.html @@ -526,19 +526,10 @@ " >our Discord! Or, - unsubscribe. + click here to manage your notifications. diff --git a/functions/src/email-templates/market-answer.html b/functions/src/email-templates/market-answer.html index 1f7fa5fa..b2d7f727 100644 --- a/functions/src/email-templates/market-answer.html +++ b/functions/src/email-templates/market-answer.html @@ -367,14 +367,9 @@ margin: 0; ">our Discord! Or, unsubscribe. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications. diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index fa44c1d5..ee7976b0 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -485,14 +485,9 @@ margin: 0; ">our Discord! Or, unsubscribe. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications. diff --git a/functions/src/email-templates/market-comment.html b/functions/src/email-templates/market-comment.html index 0b5b9a54..23e20dac 100644 --- a/functions/src/email-templates/market-comment.html +++ b/functions/src/email-templates/market-comment.html @@ -367,14 +367,9 @@ margin: 0; ">our Discord! Or, unsubscribe. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications. diff --git a/functions/src/email-templates/market-resolved-no-bets.html b/functions/src/email-templates/market-resolved-no-bets.html new file mode 100644 index 00000000..ff5f541f --- /dev/null +++ b/functions/src/email-templates/market-resolved-no-bets.html @@ -0,0 +1,491 @@ + + + + + + + Market resolved + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + + + + +
+ + Manifold Markets + +
+ {{creatorName}} asked +
+ + {{question}} +
+

+ Resolved {{outcome}} +

+
+ + + + + + + +
+ Dear {{name}}, +
+
+ A market you were following has been resolved! +
+
+ Thanks, +
+ Manifold Team +
+
+
+ +
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/functions/src/email-templates/market-resolved.html b/functions/src/email-templates/market-resolved.html index c1ff3beb..de29a0f1 100644 --- a/functions/src/email-templates/market-resolved.html +++ b/functions/src/email-templates/market-resolved.html @@ -500,14 +500,9 @@ margin: 0; ">our Discord! Or, unsubscribe. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications. diff --git a/functions/src/email-templates/new-market-from-followed-user.html b/functions/src/email-templates/new-market-from-followed-user.html new file mode 100644 index 00000000..877d554f --- /dev/null +++ b/functions/src/email-templates/new-market-from-followed-user.html @@ -0,0 +1,354 @@ + + + + + New market from {{creatorName}} + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + + banner logo + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

+ {{creatorName}}, (who you're following) just created a new market, check it out!

+
+
+
+ + {{questionTitle}} + +
+ + + + + +
+ + View market + +
+ +
+
+ +
+
+ + +
+ + + + + + +
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

+ 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/new-unique-bettor.html b/functions/src/email-templates/new-unique-bettor.html new file mode 100644 index 00000000..30da8b99 --- /dev/null +++ b/functions/src/email-templates/new-unique-bettor.html @@ -0,0 +1,397 @@ + + + + + + + New unique predictors on your market + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + +
+ + Manifold Markets + +
+
+

+ Hi {{name}},

+
+
+
+

+ Your market {{marketTitle}} just got its first prediction from a user! +
+
+ We sent you a {{bonusString}} bonus for + creating a market that appeals to others, and we'll do so for each new predictor. +
+
+ Keep up the good work and check out your newest predictor below! +

+
+
+ + + + + + + +
+
+ + {{bettor1Name}} + {{bet1Description}} +
+
+ +
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/functions/src/email-templates/new-unique-bettors.html b/functions/src/email-templates/new-unique-bettors.html new file mode 100644 index 00000000..eb4c04e2 --- /dev/null +++ b/functions/src/email-templates/new-unique-bettors.html @@ -0,0 +1,501 @@ + + + + + + + New unique predictors on your market + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + +
+ + Manifold Markets + +
+
+

+ Hi {{name}},

+
+
+
+

+ Your market {{marketTitle}} got predictions from a total of {{totalPredictors}} users! +
+
+ We sent you a {{bonusString}} bonus for getting {{newPredictors}} new predictors, + and we'll continue to do so for each new predictor, (although we won't send you any more emails about it for this market). +
+
+ Keep up the good work and check out your newest predictors below! +

+
+
+ + + + + + + + + + + + + + + +
+
+ + {{bettor1Name}} + {{bet1Description}} +
+
+
+ + {{bettor2Name}} + {{bet2Description}} +
+
+
+ + {{bettor3Name}} + {{bet3Description}} +
+
+
+ + {{bettor4Name}} + {{bet4Description}} +
+
+
+ + {{bettor5Name}} + {{bet5Description}} +
+
+ +
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/functions/src/email-templates/one-week.html b/functions/src/email-templates/one-week.html index 94889772..b8e233d5 100644 --- a/functions/src/email-templates/one-week.html +++ b/functions/src/email-templates/one-week.html @@ -1,519 +1,316 @@ - - - 7th Day Anniversary Gift! - - - - - - - - - + + + + - - + - - + + + + + +
+ +
+ + + +
+ +
+ + + + - - -
+ - - - - - - - - - - - - + + +
- - - - - - -
- -
-
-
-

- Hopefully you haven't gambled all your M$ - away already... but if you have I bring good - news! Click the link below to recieve a one time - gift of M$ 500 to your account! -

-
-
- - - - - - -
- - Get M$500 - << /td> -
-
-
-

- If you are still engaging with our markets then - at this point you might as well join our Discord server. - You can always leave if you dont like it but - I'd be willing to make a market betting - you'll stay. -

-

-
-

- Cheers, -

-

- David from Manifold -

-

-
-
- - -
-
- -
- - - - + + +
- -
- - + + + + + + + + + + + + + + + + + + +
+
+

+ 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

+

 

+
+
+
+ +
+
+ +
+ + + + diff --git a/functions/src/email-templates/welcome.html b/functions/src/email-templates/welcome.html index 366709e3..dccec695 100644 --- a/functions/src/email-templates/welcome.html +++ b/functions/src/email-templates/welcome.html @@ -137,7 +137,7 @@ style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;" data-testid="4XoHRGw1Y"> - Welcome! Manifold Markets is a play-money prediction market platform where you can bet on + Welcome! Manifold Markets is a play-money prediction market platform where you can predict anything, from elections to Elon Musk to scientific papers to the NBA.

@@ -286,9 +286,12 @@ style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
-

This e-mail has been sent to {{name}}, click here to unsubscribe.

+

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

diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 2c9c6f12..bb9f7195 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,10 +1,12 @@ import { DOMAIN } from '../../common/envs/constants' -import { Answer } from '../../common/answer' import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' -import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' -import { PrivateUser, User } from '../../common/user' +import { + notification_subscription_types, + PrivateUser, + User, +} from '../../common/user' import { formatLargeNumber, formatMoney, @@ -14,15 +16,17 @@ import { getValueFromBucket } from '../../common/calculate-dpm' import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail, sendTextEmail } from './send-email' -import { getPrivateUser, getUser } from './utils' -import { getFunctionUrl } from '../../common/api' -import { richTextToString } from '../../common/util/parse' +import { getUser } from './utils' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' - -const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') +import { + notification_reason_types, + getDestinationsForUser, +} from '../../common/notification' +import { Dictionary } from 'lodash' export const sendMarketResolutionEmail = async ( - userId: string, + reason: notification_reason_types, + privateUser: PrivateUser, investment: number, payout: number, creator: User, @@ -32,15 +36,11 @@ export const sendMarketResolutionEmail = async ( resolutionProbability?: number, resolutions?: { [outcome: string]: number } ) => { - const privateUser = await getPrivateUser(userId) - if ( - !privateUser || - privateUser.unsubscribedFromResolutionEmails || - !privateUser.email - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser || !privateUser.email || !sendToEmail) return - const user = await getUser(userId) + const user = await getUser(privateUser.id) if (!user) return const outcome = toDisplayResolution( @@ -53,17 +53,13 @@ export const sendMarketResolutionEmail = async ( const subject = `Resolved ${outcome}: ${contract.question}` const creatorPayoutText = - creatorPayout >= 1 && userId === creator.id + creatorPayout >= 1 && privateUser.id === creator.id ? ` (plus ${formatMoney(creatorPayout)} in commissions)` : '' - const emailType = 'market-resolved' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - - const displayedInvestment = - Number.isNaN(investment) || investment < 0 - ? formatMoney(0) - : formatMoney(investment) + const correctedInvestment = + Number.isNaN(investment) || investment < 0 ? 0 : investment + const displayedInvestment = formatMoney(correctedInvestment) const displayedPayout = formatMoney(payout) @@ -85,7 +81,7 @@ export const sendMarketResolutionEmail = async ( return await sendTemplateEmail( privateUser.email, subject, - 'market-resolved', + correctedInvestment === 0 ? 'market-resolved-no-bets' : 'market-resolved', templateData ) } @@ -154,11 +150,12 @@ export const sendWelcomeEmail = async ( ) => { if (!privateUser || !privateUser.email) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'onboarding_flow' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, @@ -166,7 +163,7 @@ export const sendWelcomeEmail = async ( 'welcome', { name: firstName, - unsubscribeLink, + unsubscribeUrl, }, { from: 'David from Manifold ', @@ -217,23 +214,23 @@ export const sendOneWeekBonusEmail = async ( if ( !privateUser || !privateUser.email || - privateUser.unsubscribedFromGenericEmails + !privateUser.notificationPreferences.onboarding_flow.includes('email') ) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'onboarding_flow' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, 'Manifold Markets one week anniversary gift', 'one-week', { name: firstName, - unsubscribeLink, + unsubscribeUrl, manalink: 'https://manifold.markets/link/lj4JbBvE', }, { @@ -250,23 +247,23 @@ export const sendCreatorGuideEmail = async ( if ( !privateUser || !privateUser.email || - privateUser.unsubscribedFromGenericEmails + !privateUser.notificationPreferences.onboarding_flow.includes('email') ) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'onboarding_flow' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, 'Create your own prediction market', 'creating-market', { name: firstName, - unsubscribeLink, + unsubscribeUrl, }, { from: 'David from Manifold ', @@ -282,15 +279,18 @@ export const sendThankYouEmail = async ( if ( !privateUser || !privateUser.email || - privateUser.unsubscribedFromGenericEmails + !privateUser.notificationPreferences.thank_you_for_purchases.includes( + 'email' + ) ) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'thank_you_for_purchases' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, @@ -298,7 +298,7 @@ export const sendThankYouEmail = async ( 'thank-you', { name: firstName, - unsubscribeLink, + unsubscribeUrl, }, { from: 'David from Manifold ', @@ -307,16 +307,15 @@ export const sendThankYouEmail = async ( } export const sendMarketCloseEmail = async ( + reason: notification_reason_types, user: User, privateUser: PrivateUser, contract: Contract ) => { - if ( - !privateUser || - privateUser.unsubscribedFromResolutionEmails || - !privateUser.email - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + + if (!privateUser.email || !sendToEmail) return const { username, name, id: userId } = user const firstName = name.split(' ')[0] @@ -324,8 +323,6 @@ export const sendMarketCloseEmail = async ( const { question, slug, volume } = contract const url = `https://${DOMAIN}/${username}/${slug}` - const emailType = 'market-resolve' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` return await sendTemplateEmail( privateUser.email, @@ -343,30 +340,24 @@ export const sendMarketCloseEmail = async ( } export const sendNewCommentEmail = async ( - userId: string, + reason: notification_reason_types, + privateUser: PrivateUser, commentCreator: User, contract: Contract, - comment: Comment, + commentText: string, + commentId: string, bet?: Bet, answerText?: string, answerId?: string ) => { - const privateUser = await getPrivateUser(userId) - if ( - !privateUser || - !privateUser.email || - privateUser.unsubscribedFromCommentEmails - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser || !privateUser.email || !sendToEmail) return - const { question, creatorUsername, slug } = contract - const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}` - const emailType = 'market-comment' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const { question } = contract + const marketUrl = `https://${DOMAIN}/${contract.creatorUsername}/${contract.slug}#${commentId}` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator - const { content } = comment - const text = richTextToString(content) let betDescription = '' if (bet) { @@ -380,7 +371,7 @@ export const sendNewCommentEmail = async ( const from = `${commentorName} on Manifold ` if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { - const answerNumber = `#${answerId}` + const answerNumber = answerId ? `#${answerId}` : '' return await sendTemplateEmail( privateUser.email, @@ -391,7 +382,7 @@ export const sendNewCommentEmail = async ( answerNumber, commentorName, commentorAvatarUrl: commentorAvatarUrl ?? '', - comment: text, + comment: commentText, marketUrl, unsubscribeUrl, betDescription, @@ -412,7 +403,7 @@ export const sendNewCommentEmail = async ( { commentorName, commentorAvatarUrl: commentorAvatarUrl ?? '', - comment: text, + comment: commentText, marketUrl, unsubscribeUrl, betDescription, @@ -423,29 +414,24 @@ export const sendNewCommentEmail = async ( } export const sendNewAnswerEmail = async ( - answer: Answer, - contract: Contract + reason: notification_reason_types, + privateUser: PrivateUser, + name: string, + text: string, + contract: Contract, + avatarUrl?: string ) => { - // Send to just the creator for now. - const { creatorId: userId } = contract - + const { creatorId } = contract // Don't send the creator's own answers. - if (answer.userId === userId) return + if (privateUser.id === creatorId) return - const privateUser = await getPrivateUser(userId) - if ( - !privateUser || - !privateUser.email || - privateUser.unsubscribedFromAnswerEmails - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser.email || !sendToEmail) return const { question, creatorUsername, slug } = contract - const { name, avatarUrl, text } = answer const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}` - const emailType = 'market-answer' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const subject = `New answer on ${question}` const from = `${name} ` @@ -474,12 +460,13 @@ export const sendInterestingMarketsEmail = async ( if ( !privateUser || !privateUser.email || - privateUser?.unsubscribedFromWeeklyTrendingEmails + !privateUser.notificationPreferences.trending_markets.includes('email') ) return - const emailType = 'weekly-trending' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${privateUser.id}&type=${emailType}` + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'trending_markets' as keyof notification_subscription_types + }` const { name } = user const firstName = name.split(' ')[0] @@ -490,7 +477,7 @@ export const sendInterestingMarketsEmail = async ( 'interesting-markets', { name: firstName, - unsubscribeLink: unsubscribeUrl, + unsubscribeUrl, question1Title: contractsToSend[0].question, question1Link: contractUrl(contractsToSend[0]), @@ -522,3 +509,97 @@ function contractUrl(contract: Contract) { function imageSourceUrl(contract: Contract) { return buildCardUrl(getOpenGraphProps(contract)) } + +export const sendNewFollowedMarketEmail = async ( + reason: notification_reason_types, + userId: string, + privateUser: PrivateUser, + contract: Contract +) => { + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser.email || !sendToEmail) return + const user = await getUser(privateUser.id) + if (!user) return + + const { name } = user + const firstName = name.split(' ')[0] + const creatorName = contract.creatorName + + return await sendTemplateEmail( + privateUser.email, + `${creatorName} asked ${contract.question}`, + 'new-market-from-followed-user', + { + name: firstName, + creatorName, + unsubscribeUrl, + questionTitle: contract.question, + questionUrl: contractUrl(contract), + questionImgSrc: imageSourceUrl(contract), + }, + { + from: `${creatorName} on Manifold `, + } + ) +} +export const sendNewUniqueBettorsEmail = async ( + reason: notification_reason_types, + userId: string, + privateUser: PrivateUser, + contract: Contract, + totalPredictors: number, + newPredictors: User[], + userBets: Dictionary<[Bet, ...Bet[]]>, + bonusAmount: number +) => { + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser.email || !sendToEmail) return + const user = await getUser(privateUser.id) + if (!user) return + + const { name } = user + const firstName = name.split(' ')[0] + const creatorName = contract.creatorName + // make the emails stack for the same contract + const subject = `You made a popular market! ${ + contract.question.length > 50 + ? contract.question.slice(0, 50) + '...' + : contract.question + } just got ${ + newPredictors.length + } new predictions. Check out who's predicting on it inside.` + const templateData: Record = { + name: firstName, + creatorName, + totalPredictors: totalPredictors.toString(), + bonusString: formatMoney(bonusAmount), + marketTitle: contract.question, + marketUrl: contractUrl(contract), + unsubscribeUrl, + newPredictors: newPredictors.length.toString(), + } + + newPredictors.forEach((p, i) => { + templateData[`bettor${i + 1}Name`] = p.name + if (p.avatarUrl) templateData[`bettor${i + 1}AvatarUrl`] = p.avatarUrl + const bet = userBets[p.id][0] + if (bet) { + const { amount, sale } = bet + templateData[`bet${i + 1}Description`] = `${ + sale || amount < 0 ? 'sold' : 'bought' + } ${formatMoney(Math.abs(amount))}` + } + }) + + return await sendTemplateEmail( + privateUser.email, + subject, + newPredictors.length === 1 ? 'new-unique-bettor' : 'new-unique-bettors', + templateData, + { + from: `Manifold Markets `, + } + ) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index be73b6af..adfee75e 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -71,6 +71,7 @@ import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' import { createpost } from './create-post' +import { savetwitchcredentials } from './save-twitch-credentials' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -96,6 +97,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) const createPostFunction = toCloudFunction(createpost) +const saveTwitchCredentials = toCloudFunction(savetwitchcredentials) export { healthFunction as health, @@ -119,4 +121,5 @@ export { getCurrentUserFunction as getcurrentuser, acceptChallenge as acceptchallenge, createPostFunction as createpost, + saveTwitchCredentials as savetwitchcredentials } diff --git a/functions/src/market-close-notifications.ts b/functions/src/market-close-notifications.ts index f31674a1..7878e410 100644 --- a/functions/src/market-close-notifications.ts +++ b/functions/src/market-close-notifications.ts @@ -3,7 +3,6 @@ import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' import { getPrivateUser, getUserByUsername } from './utils' -import { sendMarketCloseEmail } from './emails' import { createNotification } from './create-notification' export const marketCloseNotifications = functions @@ -56,7 +55,6 @@ async function sendMarketCloseEmails() { const privateUser = await getPrivateUser(user.id) if (!privateUser) continue - await sendMarketCloseEmail(user, privateUser, contract) await createNotification( contract.id, 'contract', diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 5dbebfc3..5fe3fd62 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -24,12 +24,16 @@ import { } from '../../common/antes' import { APIError } from '../../common/api' import { User } from '../../common/user' +import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes' +import { addHouseLiquidity } from './add-liquidity' +import { DAY_MS } from '../../common/util/time' const firestore = admin.firestore() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() -export const onCreateBet = functions.firestore - .document('contracts/{contractId}/bets/{betId}') +export const onCreateBet = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .firestore.document('contracts/{contractId}/bets/{betId}') .onCreate(async (change, context) => { const { contractId } = context.params as { contractId: string @@ -58,6 +62,12 @@ export const onCreateBet = functions.firestore const bettor = await getUser(bet.userId) if (!bettor) return + await change.ref.update({ + userAvatarUrl: bettor.avatarUrl, + userName: bettor.name, + userUsername: bettor.username, + }) + await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor) await notifyFills(bet, contract, eventId, bettor) await updateBettingStreak(bettor, bet, contract, eventId) @@ -71,12 +81,16 @@ const updateBettingStreak = async ( contract: Contract, eventId: string ) => { - const betStreakResetTime = getTodaysBettingStreakResetTime() + const now = Date.now() + const currentDateResetTime = currentDateBettingStreakResetTime() + // if now is before reset time, use yesterday's reset time + const lastDateResetTime = currentDateResetTime - DAY_MS + const betStreakResetTime = + now < currentDateResetTime ? lastDateResetTime : currentDateResetTime const lastBetTime = user?.lastBetTime ?? 0 - // If they've already bet after the reset time, or if we haven't hit the reset time yet - if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime) - return + // If they've already bet after the reset time + if (lastBetTime > betStreakResetTime) return const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1 // Otherwise, add 1 to their betting streak @@ -119,6 +133,7 @@ const updateBettingStreak = async ( bet, contract, bonusAmount, + newBettingStreak, eventId ) } @@ -148,12 +163,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( } const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id) - const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id]) + // Update contract unique bettors if (!contract.uniqueBettorIds || isNewUniqueBettor) { log(`Got ${previousUniqueBettorIds} unique bettors`) isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`) + await firestore.collection(`contracts`).doc(contract.id).update({ uniqueBettorIds: newUniqueBettorIds, uniqueBettorCount: newUniqueBettorIds.length, @@ -163,6 +179,10 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( // No need to give a bonus for the creator's bet if (!isNewUniqueBettor || bettor.id == contract.creatorId) return + if (contract.mechanism === 'cpmm-1') { + await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT) + } + // Create combined txn for all new unique bettors const bonusTxnDetails = { contractId: contract.id, @@ -198,6 +218,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( result.txn.id, contract, result.txn.amount, + newUniqueBettorIds, eventId + '-unique-bettor-bonus' ) } @@ -244,6 +265,6 @@ const notifyFills = async ( ) } -const getTodaysBettingStreakResetTime = () => { +const currentDateBettingStreakResetTime = () => { return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) } diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index a36a8bca..65e32dca 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -1,14 +1,13 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { compact, uniq } from 'lodash' +import { compact } from 'lodash' import { getContract, getUser, getValues } from './utils' import { ContractComment } from '../../common/comment' -import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' import { createCommentOrAnswerOrUpdatedContractNotification, - filterUserIdsForOnlyFollowerIds, + replied_users_info, } from './create-notification' import { parseMentions, richTextToString } from '../../common/util/parse' import { addUserToContractFollowers } from './follow-market' @@ -77,16 +76,46 @@ export const onCreateCommentOnContract = functions const comments = await getValues( firestore.collection('contracts').doc(contractId).collection('comments') ) - const relatedSourceType = comment.replyToCommentId - ? 'comment' - : comment.answerOutcome + const repliedToType = answer ? 'answer' + : comment.replyToCommentId + ? 'comment' : undefined const repliedUserId = comment.replyToCommentId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId + const mentionedUsers = compact(parseMentions(comment.content)) + const repliedUsers: replied_users_info = {} + + // The parent of the reply chain could be a comment or an answer + if (repliedUserId && repliedToType) + repliedUsers[repliedUserId] = { + repliedToType, + repliedToAnswerText: answer ? answer.text : undefined, + repliedToId: comment.replyToCommentId || answer?.id, + bet: bet, + } + + const commentsInSameReplyChain = comments.filter((c) => + repliedToType === 'answer' + ? c.answerOutcome === answer?.id + : repliedToType === 'comment' + ? c.replyToCommentId === comment.replyToCommentId + : false + ) + // The rest of the children in the chain are always comments + commentsInSameReplyChain.forEach((c) => { + if (c.userId !== comment.userId && c.userId !== repliedUserId) { + repliedUsers[c.userId] = { + repliedToType: 'comment', + repliedToAnswerText: undefined, + repliedToId: c.id, + bet: undefined, + } + } + }) await createCommentOrAnswerOrUpdatedContractNotification( comment.id, 'comment', @@ -96,31 +125,8 @@ export const onCreateCommentOnContract = functions richTextToString(comment.content), contract, { - relatedSourceType, - repliedUserId, - taggedUserIds: compact(parseMentions(comment.content)), + repliedUsersInfo: repliedUsers, + taggedUserIds: mentionedUsers, } ) - - const recipientUserIds = await filterUserIdsForOnlyFollowerIds( - uniq([ - contract.creatorId, - ...comments.map((comment) => comment.userId), - ]).filter((id) => id !== comment.userId), - contractId - ) - - await Promise.all( - recipientUserIds.map((userId) => - sendNewCommentEmail( - userId, - commentCreator, - contract, - comment, - bet, - answer?.text, - answer?.id - ) - ) - ) }) diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index d9826f6c..b613142b 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -1,7 +1,7 @@ import * as functions from 'firebase-functions' import { getUser } from './utils' -import { createNotification } from './create-notification' +import { createNewContractNotification } from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' @@ -21,13 +21,11 @@ export const onCreateContract = functions const mentioned = parseMentions(desc) await addUserToContractFollowers(contract.id, contractCreator.id) - await createNotification( - contract.id, - 'contract', - 'created', + await createNewContractNotification( contractCreator, + contract, eventId, richTextToString(desc), - { contract, recipients: mentioned } + mentioned ) }) diff --git a/functions/src/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts index 3a1e551f..54da7fd9 100644 --- a/functions/src/on-create-liquidity-provision.ts +++ b/functions/src/on-create-liquidity-provision.ts @@ -7,6 +7,7 @@ import { FIXED_ANTE } from '../../common/economy' import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, + UNIQUE_BETTOR_LIQUIDITY_AMOUNT, } from '../../common/antes' export const onCreateLiquidityProvision = functions.firestore @@ -17,9 +18,11 @@ export const onCreateLiquidityProvision = functions.firestore // Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision if ( - (liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID || + liquidity.isAnte || + ((liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID || liquidity.userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID) && - liquidity.amount === FIXED_ANTE + (liquidity.amount === FIXED_ANTE || + liquidity.amount === UNIQUE_BETTOR_LIQUIDITY_AMOUNT)) ) return diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index d7ecd56e..2972a305 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -13,32 +13,7 @@ export const onUpdateContract = functions.firestore if (!contractUpdater) throw new Error('Could not find contract updater') const previousValue = change.before.data() as Contract - if (previousValue.isResolved !== contract.isResolved) { - let resolutionText = contract.resolution ?? contract.question - if (contract.outcomeType === 'FREE_RESPONSE') { - const answerText = contract.answers.find( - (answer) => answer.id === contract.resolution - )?.text - if (answerText) resolutionText = answerText - } else if (contract.outcomeType === 'BINARY') { - if (resolutionText === 'MKT' && contract.resolutionProbability) - resolutionText = `${contract.resolutionProbability}%` - else if (resolutionText === 'MKT') resolutionText = 'PROB' - } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { - if (resolutionText === 'MKT' && contract.resolutionValue) - resolutionText = `${contract.resolutionValue}` - } - - await createCommentOrAnswerOrUpdatedContractNotification( - contract.id, - 'contract', - 'resolved', - contractUpdater, - eventId, - resolutionText, - contract - ) - } else if ( + if ( previousValue.closeTime !== contract.closeTime || previousValue.question !== contract.question ) { diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index d98430c1..74df7dc3 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -139,7 +139,14 @@ export const placebet = newEndpoint({}, async (req, auth) => { } const betDoc = contractDoc.collection('bets').doc() - trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet }) + trans.create(betDoc, { + id: betDoc.id, + userId: user.id, + userAvatarUrl: user.avatarUrl, + userUsername: user.username, + userName: user.name, + ...newBet, + }) log('Created new bet document.') if (makers) { diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 6f8ea2e9..b867b609 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -1,6 +1,6 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { difference, mapValues, groupBy, sumBy } from 'lodash' +import { mapValues, groupBy, sumBy } from 'lodash' import { Contract, @@ -8,10 +8,8 @@ import { MultipleChoiceContract, RESOLUTIONS, } from '../../common/contract' -import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, isProd, payUser } from './utils' -import { sendMarketResolutionEmail } from './emails' import { getLoanPayouts, getPayouts, @@ -23,7 +21,7 @@ import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' import { APIError, newEndpoint, validate } from './api' import { getContractBetMetrics } from '../../common/calculate' -import { floatingEqual } from '../../common/util/math' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' const bodySchema = z.object({ contractId: z.string(), @@ -163,15 +161,48 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) - await sendResolutionEmails( - bets, - userPayoutsWithoutLoans, + const userInvestments = mapValues( + groupBy(bets, (bet) => bet.userId), + (bets) => getContractBetMetrics(contract, bets).invested + ) + let resolutionText = outcome ?? contract.question + if ( + contract.outcomeType === 'FREE_RESPONSE' || + contract.outcomeType === 'MULTIPLE_CHOICE' + ) { + const answerText = contract.answers.find( + (answer) => answer.id === outcome + )?.text + if (answerText) resolutionText = answerText + } else if (contract.outcomeType === 'BINARY') { + if (resolutionText === 'MKT' && probabilityInt) + resolutionText = `${probabilityInt}%` + else if (resolutionText === 'MKT') resolutionText = 'PROB' + } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { + if (resolutionText === 'MKT' && value) resolutionText = `${value}` + } + + // TODO: this actually may be too slow to complete with a ton of users to notify? + await createCommentOrAnswerOrUpdatedContractNotification( + contract.id, + 'contract', + 'resolved', creator, - creatorPayout, + contract.id + '-resolution', + resolutionText, contract, - outcome, - resolutionProbability, - resolutions + undefined, + { + bets, + userInvestments, + userPayouts: userPayoutsWithoutLoans, + creator, + creatorPayout, + contract, + outcome, + resolutionProbability, + resolutions, + } ) return updatedContract @@ -189,51 +220,6 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => { .then(() => ({ status: 'success' })) } -const sendResolutionEmails = async ( - bets: Bet[], - userPayouts: { [userId: string]: number }, - creator: User, - creatorPayout: number, - contract: Contract, - outcome: string, - resolutionProbability?: number, - resolutions?: { [outcome: string]: number } -) => { - const investedByUser = mapValues( - groupBy(bets, (bet) => bet.userId), - (bets) => getContractBetMetrics(contract, bets).invested - ) - const investedUsers = Object.keys(investedByUser).filter( - (userId) => !floatingEqual(investedByUser[userId], 0) - ) - - const nonWinners = difference(investedUsers, Object.keys(userPayouts)) - const emailPayouts = [ - ...Object.entries(userPayouts), - ...nonWinners.map((userId) => [userId, 0] as const), - ].map(([userId, payout]) => ({ - userId, - investment: investedByUser[userId] ?? 0, - payout, - })) - - await Promise.all( - emailPayouts.map(({ userId, investment, payout }) => - sendMarketResolutionEmail( - userId, - investment, - payout, - creator, - creatorPayout, - contract, - outcome, - resolutionProbability, - resolutions - ) - ) - ) -} - function getResolutionParams(contract: Contract, body: string) { const { outcomeType } = contract diff --git a/functions/src/save-twitch-credentials.ts b/functions/src/save-twitch-credentials.ts new file mode 100644 index 00000000..80dc86a6 --- /dev/null +++ b/functions/src/save-twitch-credentials.ts @@ -0,0 +1,22 @@ +import * as admin from 'firebase-admin' +import { z } from 'zod' + +import { newEndpoint, validate } from './api' + +const bodySchema = z.object({ + twitchInfo: z.object({ + twitchName: z.string(), + controlToken: z.string(), + }), +}) + + +export const savetwitchcredentials = newEndpoint({}, async (req, auth) => { + const { twitchInfo } = validate(bodySchema, req.body) + const userId = auth.uid + + await firestore.doc(`private-users/${userId}`).update({ twitchInfo }) + return { success: true } +}) + +const firestore = admin.firestore() diff --git a/functions/src/scripts/create-new-notification-preferences.ts b/functions/src/scripts/create-new-notification-preferences.ts new file mode 100644 index 00000000..2796f2f7 --- /dev/null +++ b/functions/src/scripts/create-new-notification-preferences.ts @@ -0,0 +1,30 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getDefaultNotificationSettings } from 'common/user' +import { getAllPrivateUsers, isProd } from 'functions/src/utils' +initAdmin() + +const firestore = admin.firestore() + +async function main() { + const privateUsers = await getAllPrivateUsers() + const disableEmails = !isProd() + await Promise.all( + privateUsers.map((privateUser) => { + if (!privateUser.id) return Promise.resolve() + return firestore + .collection('private-users') + .doc(privateUser.id) + .update({ + notificationPreferences: getDefaultNotificationSettings( + privateUser.id, + privateUser, + disableEmails + ), + }) + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) diff --git a/functions/src/scripts/create-private-users.ts b/functions/src/scripts/create-private-users.ts index acce446e..21e117cf 100644 --- a/functions/src/scripts/create-private-users.ts +++ b/functions/src/scripts/create-private-users.ts @@ -3,7 +3,7 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' initAdmin() -import { PrivateUser, User } from 'common/user' +import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user' import { STARTING_BALANCE } from 'common/economy' const firestore = admin.firestore() @@ -21,6 +21,7 @@ async function main() { id: user.id, email, username, + notificationPreferences: getDefaultNotificationSettings(user.id), } if (user.totalDeposits === undefined) { diff --git a/functions/src/scripts/denormalize-avatar-urls.ts b/functions/src/scripts/denormalize-avatar-urls.ts index 23b7dfc9..fd95ec8f 100644 --- a/functions/src/scripts/denormalize-avatar-urls.ts +++ b/functions/src/scripts/denormalize-avatar-urls.ts @@ -3,12 +3,7 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -import { - DocumentCorrespondence, - findDiffs, - describeDiff, - applyDiff, -} from './denormalize' +import { findDiffs, describeDiff, applyDiff } from './denormalize' import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' initAdmin() @@ -79,43 +74,36 @@ if (require.main === module) { getAnswersByUserId(transaction), ]) - const usersContracts = Array.from( - usersById.entries(), - ([id, doc]): DocumentCorrespondence => { - return [doc, contractsByUserId.get(id) || []] - } - ) - const contractDiffs = findDiffs( - usersContracts, + const usersContracts = Array.from(usersById.entries(), ([id, doc]) => { + return [doc, contractsByUserId.get(id) || []] as const + }) + const contractDiffs = findDiffs(usersContracts, [ 'avatarUrl', - 'creatorAvatarUrl' - ) + 'creatorAvatarUrl', + ]) console.log(`Found ${contractDiffs.length} contracts with mismatches.`) contractDiffs.forEach((d) => { console.log(describeDiff(d)) applyDiff(transaction, d) }) - const usersComments = Array.from( - usersById.entries(), - ([id, doc]): DocumentCorrespondence => { - return [doc, commentsByUserId.get(id) || []] - } - ) - const commentDiffs = findDiffs(usersComments, 'avatarUrl', 'userAvatarUrl') + const usersComments = Array.from(usersById.entries(), ([id, doc]) => { + return [doc, commentsByUserId.get(id) || []] as const + }) + const commentDiffs = findDiffs(usersComments, [ + 'avatarUrl', + 'userAvatarUrl', + ]) console.log(`Found ${commentDiffs.length} comments with mismatches.`) commentDiffs.forEach((d) => { console.log(describeDiff(d)) applyDiff(transaction, d) }) - const usersAnswers = Array.from( - usersById.entries(), - ([id, doc]): DocumentCorrespondence => { - return [doc, answersByUserId.get(id) || []] - } - ) - const answerDiffs = findDiffs(usersAnswers, 'avatarUrl', 'avatarUrl') + const usersAnswers = Array.from(usersById.entries(), ([id, doc]) => { + return [doc, answersByUserId.get(id) || []] as const + }) + const answerDiffs = findDiffs(usersAnswers, ['avatarUrl', 'avatarUrl']) console.log(`Found ${answerDiffs.length} answers with mismatches.`) answerDiffs.forEach((d) => { console.log(describeDiff(d)) diff --git a/functions/src/scripts/denormalize-bet-user-data.ts b/functions/src/scripts/denormalize-bet-user-data.ts new file mode 100644 index 00000000..3c86e140 --- /dev/null +++ b/functions/src/scripts/denormalize-bet-user-data.ts @@ -0,0 +1,38 @@ +// Filling in the user-based fields on bets. + +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' +import { findDiffs, describeDiff, getDiffUpdate } from './denormalize' +import { log, writeAsync } from '../utils' + +initAdmin() +const firestore = admin.firestore() + +// not in a transaction for speed -- may need to be run more than once +async function denormalize() { + const users = await firestore.collection('users').get() + log(`Found ${users.size} users.`) + for (const userDoc of users.docs) { + const userBets = await firestore + .collectionGroup('bets') + .where('userId', '==', userDoc.id) + .get() + const mapping = [[userDoc, userBets.docs] as const] as const + const diffs = findDiffs( + mapping, + ['avatarUrl', 'userAvatarUrl'], + ['name', 'userName'], + ['username', 'userUsername'] + ) + log(`Found ${diffs.length} bets with mismatched user data.`) + const updates = diffs.map((d) => { + log(describeDiff(d)) + return getDiffUpdate(d) + }) + await writeAsync(firestore, updates) + } +} + +if (require.main === module) { + denormalize().catch((e) => console.error(e)) +} diff --git a/functions/src/scripts/denormalize-comment-bet-data.ts b/functions/src/scripts/denormalize-comment-bet-data.ts index 929626c3..a5fb8759 100644 --- a/functions/src/scripts/denormalize-comment-bet-data.ts +++ b/functions/src/scripts/denormalize-comment-bet-data.ts @@ -3,12 +3,7 @@ import * as admin from 'firebase-admin' import { zip } from 'lodash' import { initAdmin } from './script-init' -import { - DocumentCorrespondence, - findDiffs, - describeDiff, - applyDiff, -} from './denormalize' +import { findDiffs, describeDiff, applyDiff } from './denormalize' import { log } from '../utils' import { Transaction } from 'firebase-admin/firestore' @@ -41,17 +36,20 @@ async function denormalize() { ) ) log(`Found ${bets.length} bets associated with comments.`) - const mapping = zip(bets, betComments) - .map(([bet, comment]): DocumentCorrespondence => { - return [bet!, [comment!]] // eslint-disable-line - }) - .filter(([bet, _]) => bet.exists) // dev DB has some invalid bet IDs - const amountDiffs = findDiffs(mapping, 'amount', 'betAmount') - const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome') - log(`Found ${amountDiffs.length} comments with mismatched amounts.`) - log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`) - const diffs = amountDiffs.concat(outcomeDiffs) + // dev DB has some invalid bet IDs + const mapping = zip(bets, betComments) + .filter(([bet, _]) => bet!.exists) // eslint-disable-line + .map(([bet, comment]) => { + return [bet!, [comment!]] as const // eslint-disable-line + }) + + const diffs = findDiffs( + mapping, + ['amount', 'betAmount'], + ['outcome', 'betOutcome'] + ) + log(`Found ${diffs.length} comments with mismatched data.`) diffs.slice(0, 500).forEach((d) => { log(describeDiff(d)) applyDiff(trans, d) diff --git a/functions/src/scripts/denormalize-comment-contract-data.ts b/functions/src/scripts/denormalize-comment-contract-data.ts index 0358c5a1..150b833d 100644 --- a/functions/src/scripts/denormalize-comment-contract-data.ts +++ b/functions/src/scripts/denormalize-comment-contract-data.ts @@ -2,12 +2,7 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -import { - DocumentCorrespondence, - findDiffs, - describeDiff, - applyDiff, -} from './denormalize' +import { findDiffs, describeDiff, applyDiff } from './denormalize' import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' initAdmin() @@ -43,16 +38,15 @@ async function denormalize() { getContractsById(transaction), getCommentsByContractId(transaction), ]) - const mapping = Object.entries(contractsById).map( - ([id, doc]): DocumentCorrespondence => { - return [doc, commentsByContractId.get(id) || []] - } + const mapping = Object.entries(contractsById).map(([id, doc]) => { + return [doc, commentsByContractId.get(id) || []] as const + }) + const diffs = findDiffs( + mapping, + ['slug', 'contractSlug'], + ['question', 'contractQuestion'] ) - const slugDiffs = findDiffs(mapping, 'slug', 'contractSlug') - const qDiffs = findDiffs(mapping, 'question', 'contractQuestion') - console.log(`Found ${slugDiffs.length} comments with mismatched slugs.`) - console.log(`Found ${qDiffs.length} comments with mismatched questions.`) - const diffs = slugDiffs.concat(qDiffs) + console.log(`Found ${diffs.length} comments with mismatched data.`) diffs.slice(0, 500).forEach((d) => { console.log(describeDiff(d)) applyDiff(transaction, d) diff --git a/functions/src/scripts/denormalize.ts b/functions/src/scripts/denormalize.ts index 20bfc458..d4feb425 100644 --- a/functions/src/scripts/denormalize.ts +++ b/functions/src/scripts/denormalize.ts @@ -2,32 +2,40 @@ // another set of documents. import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' +import { isEqual, zip } from 'lodash' +import { UpdateSpec } from '../utils' export type DocumentValue = { doc: DocumentSnapshot - field: string - val: unknown + fields: string[] + vals: unknown[] } -export type DocumentCorrespondence = [DocumentSnapshot, DocumentSnapshot[]] +export type DocumentMapping = readonly [ + DocumentSnapshot, + readonly DocumentSnapshot[] +] export type DocumentDiff = { src: DocumentValue dest: DocumentValue } +type PathPair = readonly [string, string] + export function findDiffs( - docs: DocumentCorrespondence[], - srcPath: string, - destPath: string + docs: readonly DocumentMapping[], + ...paths: PathPair[] ) { const diffs: DocumentDiff[] = [] + const srcPaths = paths.map((p) => p[0]) + const destPaths = paths.map((p) => p[1]) for (const [srcDoc, destDocs] of docs) { - const srcVal = srcDoc.get(srcPath) + const srcVals = srcPaths.map((p) => srcDoc.get(p)) for (const destDoc of destDocs) { - const destVal = destDoc.get(destPath) - if (destVal !== srcVal) { + const destVals = destPaths.map((p) => destDoc.get(p)) + if (!isEqual(srcVals, destVals)) { diffs.push({ - src: { doc: srcDoc, field: srcPath, val: srcVal }, - dest: { doc: destDoc, field: destPath, val: destVal }, + src: { doc: srcDoc, fields: srcPaths, vals: srcVals }, + dest: { doc: destDoc, fields: destPaths, vals: destVals }, }) } } @@ -37,12 +45,19 @@ export function findDiffs( export function describeDiff(diff: DocumentDiff) { function describeDocVal(x: DocumentValue): string { - return `${x.doc.ref.path}.${x.field}: ${x.val}` + return `${x.doc.ref.path}.[${x.fields.join('|')}]: [${x.vals.join('|')}]` } return `${describeDocVal(diff.src)} -> ${describeDocVal(diff.dest)}` } -export function applyDiff(transaction: Transaction, diff: DocumentDiff) { - const { src, dest } = diff - transaction.update(dest.doc.ref, dest.field, src.val) +export function getDiffUpdate(diff: DocumentDiff) { + return { + doc: diff.dest.doc.ref, + fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)), + } as UpdateSpec +} + +export function applyDiff(transaction: Transaction, diff: DocumentDiff) { + const update = getDiffUpdate(diff) + transaction.update(update.doc, update.fields) } diff --git a/functions/src/scripts/update-notification-preferences.ts b/functions/src/scripts/update-notification-preferences.ts new file mode 100644 index 00000000..efea57b8 --- /dev/null +++ b/functions/src/scripts/update-notification-preferences.ts @@ -0,0 +1,25 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getAllPrivateUsers } from 'functions/src/utils' +import { FieldValue } from 'firebase-admin/firestore' +initAdmin() + +const firestore = admin.firestore() + +async function main() { + const privateUsers = await getAllPrivateUsers() + await Promise.all( + privateUsers.map((privateUser) => { + if (!privateUser.id) return Promise.resolve() + return firestore.collection('private-users').doc(privateUser.id).update({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + notificationPreferences: privateUser.notificationSubscriptionTypes, + notificationSubscriptionTypes: FieldValue.delete(), + }) + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 0e88a0b5..f2f475cb 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -112,6 +112,9 @@ export const sellshares = newEndpoint({}, async (req, auth) => { transaction.create(newBetDoc, { id: newBetDoc.id, userId: user.id, + userAvatarUrl: user.avatarUrl, + userUsername: user.username, + userName: user.name, ...newBet, }) transaction.update( diff --git a/functions/src/serve.ts b/functions/src/serve.ts index a5291f19..6d062d40 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { createpost } from './create-post' +import { savetwitchcredentials } from './save-twitch-credentials' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -65,6 +66,7 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/getcurrentuser', getcurrentuser) +addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/createpost', createpost) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 430f3d33..1de8056c 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -4,9 +4,11 @@ import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract, CPMM } from '../../common/contract' + import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' +import { scoreTraders, scoreCreators } from '../../common/scoring' import { calculateCreatorVolume, calculateNewPortfolioMetrics, @@ -15,6 +17,7 @@ import { computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' +import { Group } from 'common/group' const firestore = admin.firestore() @@ -24,16 +27,29 @@ export const updateMetrics = functions .onRun(updateMetricsCore) export async function updateMetricsCore() { - const [users, contracts, bets, allPortfolioHistories] = await Promise.all([ - getValues(firestore.collection('users')), - getValues(firestore.collection('contracts')), - getValues(firestore.collectionGroup('bets')), - getValues( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - ]) + const [users, contracts, bets, allPortfolioHistories, groups] = + await Promise.all([ + getValues(firestore.collection('users')), + getValues(firestore.collection('contracts')), + getValues(firestore.collectionGroup('bets')), + getValues( + firestore + .collectionGroup('portfolioHistory') + .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago + ), + getValues(firestore.collection('groups')), + ]) + + const contractsByGroup = await Promise.all( + groups.map((group) => { + return getValues( + firestore + .collection('groups') + .doc(group.id) + .collection('groupContracts') + ) + }) + ) log( `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` ) @@ -41,6 +57,7 @@ export async function updateMetricsCore() { const now = Date.now() const betsByContract = groupBy(bets, (bet) => bet.contractId) + const contractUpdates = contracts .filter((contract) => contract.id) .map((contract) => { @@ -162,4 +179,48 @@ export async function updateMetricsCore() { 'set' ) log(`Updated metrics for ${users.length} users.`) + + try { + const groupUpdates = groups.map((group, index) => { + const groupContractIds = contractsByGroup[index] as GroupContractDoc[] + const groupContracts = groupContractIds + .map((e) => contractsById[e.contractId]) + .filter((e) => e !== undefined) as Contract[] + const bets = groupContracts.map((e) => { + if (e != null && e.id in betsByContract) { + return betsByContract[e.id] ?? [] + } else { + return [] + } + }) + + const creatorScores = scoreCreators(groupContracts) + const traderScores = scoreTraders(groupContracts, bets) + + const topTraderScores = topUserScores(traderScores) + const topCreatorScores = topUserScores(creatorScores) + + return { + doc: firestore.collection('groups').doc(group.id), + fields: { + cachedLeaderboard: { + topTraders: topTraderScores, + topCreators: topCreatorScores, + }, + }, + } + }) + await writeAsync(firestore, groupUpdates) + } catch (e) { + console.log('Error While Updating Group Leaderboards', e) + } } + +const topUserScores = (scores: { [userId: string]: number }) => { + const top50 = Object.entries(scores) + .sort(([, scoreA], [, scoreB]) => scoreB - scoreA) + .slice(0, 50) + return top50.map(([userId, score]) => ({ userId, score })) +} + +type GroupContractDoc = { contractId: string; createdTime: number } diff --git a/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx deleted file mode 100644 index 7ee27fb5..00000000 --- a/web/components/NotificationSettings.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { useUser } from 'web/hooks/use-user' -import React, { useEffect, useState } from 'react' -import { notification_subscribe_types, PrivateUser } from 'common/user' -import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users' -import toast from 'react-hot-toast' -import { track } from '@amplitude/analytics-browser' -import { LoadingIndicator } from 'web/components/loading-indicator' -import { Row } from 'web/components/layout/row' -import clsx from 'clsx' -import { CheckIcon, XIcon } from '@heroicons/react/outline' -import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { Col } from 'web/components/layout/col' -import { FollowMarketModal } from 'web/components/contract/follow-market-modal' - -export function NotificationSettings() { - const user = useUser() - const [notificationSettings, setNotificationSettings] = - useState('all') - const [emailNotificationSettings, setEmailNotificationSettings] = - useState('all') - const [privateUser, setPrivateUser] = useState(null) - const [showModal, setShowModal] = useState(false) - - useEffect(() => { - if (user) listenForPrivateUser(user.id, setPrivateUser) - }, [user]) - - useEffect(() => { - if (!privateUser) return - if (privateUser.notificationPreferences) { - setNotificationSettings(privateUser.notificationPreferences) - } - if ( - privateUser.unsubscribedFromResolutionEmails && - privateUser.unsubscribedFromCommentEmails && - privateUser.unsubscribedFromAnswerEmails - ) { - setEmailNotificationSettings('none') - } else if ( - !privateUser.unsubscribedFromResolutionEmails && - !privateUser.unsubscribedFromCommentEmails && - !privateUser.unsubscribedFromAnswerEmails - ) { - setEmailNotificationSettings('all') - } else { - setEmailNotificationSettings('less') - } - }, [privateUser]) - - const loading = 'Changing Notifications Settings' - const success = 'Notification Settings Changed!' - function changeEmailNotifications(newValue: notification_subscribe_types) { - if (!privateUser) return - if (newValue === 'all') { - toast.promise( - updatePrivateUser(privateUser.id, { - unsubscribedFromResolutionEmails: false, - unsubscribedFromCommentEmails: false, - unsubscribedFromAnswerEmails: false, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } else if (newValue === 'less') { - toast.promise( - updatePrivateUser(privateUser.id, { - unsubscribedFromResolutionEmails: false, - unsubscribedFromCommentEmails: true, - unsubscribedFromAnswerEmails: true, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } else if (newValue === 'none') { - toast.promise( - updatePrivateUser(privateUser.id, { - unsubscribedFromResolutionEmails: true, - unsubscribedFromCommentEmails: true, - unsubscribedFromAnswerEmails: true, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } - } - - function changeInAppNotificationSettings( - newValue: notification_subscribe_types - ) { - if (!privateUser) return - track('In-App Notification Preferences Changed', { - newPreference: newValue, - oldPreference: privateUser.notificationPreferences, - }) - toast.promise( - updatePrivateUser(privateUser.id, { - notificationPreferences: newValue, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } - - useEffect(() => { - if (privateUser && privateUser.notificationPreferences) - setNotificationSettings(privateUser.notificationPreferences) - else setNotificationSettings('all') - }, [privateUser]) - - if (!privateUser) { - return - } - - function NotificationSettingLine(props: { - label: string | React.ReactNode - highlight: boolean - onClick?: () => void - }) { - const { label, highlight, onClick } = props - return ( - - {highlight ? : } - {label} - - ) - } - - return ( -
-
In App Notifications
- - changeInAppNotificationSettings( - choice as notification_subscribe_types - ) - } - className={'col-span-4 p-2'} - toggleClassName={'w-24'} - /> -
-
- - You will receive notifications for these general events: - - - - You will receive new comment, answer, & resolution notifications on - questions: - - - That you watch - you - auto-watch questions if: - - } - onClick={() => setShowModal(true)} - /> - - • You create it - • You bet, comment on, or answer it - • You add liquidity to it - - • If you select 'Less' and you've commented on or answered a - question, you'll only receive notification on direct replies to - your comments or answers - - - - -
Email Notifications
- - changeEmailNotifications(choice as notification_subscribe_types) - } - className={'col-span-4 p-2'} - toggleClassName={'w-24'} - /> -
-
- You will receive emails for: - - - - -
-
- - - ) -} diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 5811403f..444c5701 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -23,6 +23,7 @@ import { Avatar } from 'web/components/avatar' import { Linkify } from 'web/components/linkify' import { BuyButton } from 'web/components/yes-no-selector' import { UserLink } from 'web/components/user-link' +import { Button } from 'web/components/button' export function AnswersPanel(props: { contract: FreeResponseContract | MultipleChoiceContract @@ -30,14 +31,15 @@ export function AnswersPanel(props: { const { contract } = props const { creatorId, resolution, resolutions, totalBets, outcomeType } = contract + const [showAllAnswers, setShowAllAnswers] = useState(false) + + const answers = (useAnswers(contract.id) ?? contract.answers).filter( + (a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE' + ) + const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1) - const answers = useAnswers(contract.id) ?? contract.answers const [winningAnswers, losingAnswers] = partition( - answers.filter( - (answer) => - (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && - totalBets[answer.id] > 0.000000001 - ), + answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)), (answer) => answer.id === resolution || (resolutions && resolutions[answer.id]) ) @@ -127,6 +129,17 @@ export function AnswersPanel(props: { ))} + + {hasZeroBetAnswers && !showAllAnswers && ( + + )} + )} diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 7e20e92e..58f55327 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -76,7 +76,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { if (existingAnswer) { setAnswerError( existingAnswer - ? `"${existingAnswer.text}" already exists as an answer` + ? `"${existingAnswer.text}" already exists as an answer. Can't see it? Hit the 'Show More' button right above this box.` : '' ) return @@ -237,7 +237,7 @@ const AnswerError = (props: { text: string; level: answerErrorLevel }) => { }[level] ?? '' return (
{text}
diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index 646d30fe..6be187f8 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -111,9 +111,9 @@ export const getHomeItems = (groups: Group[], sections: string[]) => { if (!isArray(sections)) sections = [] const items = [ - { label: 'Daily movers', id: 'daily-movers' }, { label: 'Trending', id: 'score' }, { label: 'New for you', id: 'newest' }, + { label: 'Daily movers', id: 'daily-movers' }, ...groups.map((g) => ({ label: g.name, id: g.id, diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 78934389..00db5cb4 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -42,6 +42,7 @@ import { YesNoSelector } from './yes-no-selector' import { PlayMoneyDisclaimer } from './play-money-disclaimer' import { isAndroid, isIOS } from 'web/lib/util/device' import { WarningConfirmationButton } from './warning-confirmation-button' +import { MarketIntroPanel } from './market-intro-panel' export function BetPanel(props: { contract: CPMMBinaryContract | PseudoNumericContract @@ -90,10 +91,7 @@ export function BetPanel(props: { /> ) : ( - <> - - - + )} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index b38ffe47..3d477ac3 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -200,7 +200,7 @@ export function ContractSearch(props: { } return ( -
+ void + submitLabel: (length: number) => string + onSubmit: (contracts: Contract[]) => void | Promise + contractSearchOptions?: Partial[0]> +}) { + const { + title, + description, + open, + setOpen, + submitLabel, + onSubmit, + contractSearchOptions, + } = props + + const [contracts, setContracts] = useState([]) + const [loading, setLoading] = useState(false) + + async function addContract(contract: Contract) { + if (contracts.map((c) => c.id).includes(contract.id)) { + setContracts(contracts.filter((c) => c.id !== contract.id)) + } else setContracts([...contracts, contract]) + } + + async function onFinish() { + setLoading(true) + await onSubmit(contracts) + setLoading(false) + setOpen(false) + setContracts([]) + } + + return ( + + +
+ +
{title}
+ + {!loading && ( + + {contracts.length > 0 && ( + + )} + + + )} +
+ {description} +
+ + {loading && ( +
+ +
+ )} + +
+ c.id), + highlightClassName: + '!bg-indigo-100 outline outline-2 outline-indigo-300', + }} + additionalFilter={{}} /* hide pills */ + headerClassName="bg-white" + {...contractSearchOptions} + /> +
+ + + ) +} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index c383d349..e28ab41a 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -294,7 +294,7 @@ export function ExtraMobileContractDetails(props: { {volumeTranslation} diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index 1eaf7043..54b2c79e 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -6,7 +6,6 @@ import { formatMoney } from 'common/util/format' import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash' import { useState, useMemo, useEffect } from 'react' import { CommentTipMap } from 'web/hooks/use-tip-txns' -import { useUserById } from 'web/hooks/use-user' import { listUsers, User } from 'web/lib/firebase/users' import { FeedBet } from '../feed/feed-bets' import { FeedComment } from '../feed/feed-comments' @@ -88,7 +87,7 @@ export function ContractTopTrades(props: { // Now find the betId with the highest profit const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id - const topBettor = useUserById(betsById[topBetId]?.userId) + const topBettor = betsById[topBetId]?.userName // And also the commentId of the comment with the highest profit const topCommentId = sortBy( @@ -121,7 +120,7 @@ export function ContractTopTrades(props: {
- {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! + {topBettor} made {formatMoney(profitById[topBetId] || 0)}!
)} diff --git a/web/components/contract/follow-market-modal.tsx b/web/components/contract/watch-market-modal.tsx similarity index 74% rename from web/components/contract/follow-market-modal.tsx rename to web/components/contract/watch-market-modal.tsx index fb62ce9f..2fb9bc00 100644 --- a/web/components/contract/follow-market-modal.tsx +++ b/web/components/contract/watch-market-modal.tsx @@ -4,7 +4,7 @@ import { EyeIcon } from '@heroicons/react/outline' import React from 'react' import clsx from 'clsx' -export const FollowMarketModal = (props: { +export const WatchMarketModal = (props: { open: boolean setOpen: (b: boolean) => void title?: string @@ -18,20 +18,21 @@ export const FollowMarketModal = (props: {
• What is watching? - You can receive notifications on questions you're interested in by + You'll receive notifications on markets by betting, commenting, or clicking the • What types of notifications will I receive? - You'll receive in-app notifications for new comments, answers, and - updates to the question. + You'll receive notifications for new comments, answers, and updates + to the question. See the notifications settings pages to customize + which types of notifications you receive on watched markets. diff --git a/web/components/editor.tsx b/web/components/editor.tsx index bb947579..745fc3c5 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -2,6 +2,7 @@ import CharacterCount from '@tiptap/extension-character-count' import Placeholder from '@tiptap/extension-placeholder' import { useEditor, + BubbleMenu, EditorContent, JSONContent, Content, @@ -24,13 +25,19 @@ import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' import { EmbedModal } from './editor/embed-modal' import { + CheckIcon, CodeIcon, PhotographIcon, PresentationChartLineIcon, + TrashIcon, } from '@heroicons/react/solid' import { MarketModal } from './editor/market-modal' import { insertContent } from './editor/utils' import { Tooltip } from './tooltip' +import BoldIcon from 'web/lib/icons/bold-icon' +import ItalicIcon from 'web/lib/icons/italic-icon' +import LinkIcon from 'web/lib/icons/link-icon' +import { getUrl } from 'common/util/parse' const DisplayImage = Image.configure({ HTMLAttributes: { @@ -141,6 +148,66 @@ function isValidIframe(text: string) { return /^$/.test(text) } +function FloatingMenu(props: { editor: Editor | null }) { + const { editor } = props + + const [url, setUrl] = useState(null) + + if (!editor) return null + + // current selection + const isBold = editor.isActive('bold') + const isItalic = editor.isActive('italic') + const isLink = editor.isActive('link') + + const setLink = () => { + const href = url && getUrl(url) + if (href) { + editor.chain().focus().extendMarkRange('link').setLink({ href }).run() + } + } + + const unsetLink = () => editor.chain().focus().unsetLink().run() + + return ( + + {url === null ? ( + <> + + + + + ) : ( + <> + setUrl(e.target.value)} + /> + + + + )} + + ) +} + export function TextEditor(props: { editor: Editor | null upload: ReturnType @@ -155,6 +222,7 @@ export function TextEditor(props: { {/* hide placeholder when focused */}
+ {/* Toolbar, with buttons for images and embeds */}
diff --git a/web/components/editor/market-modal.tsx b/web/components/editor/market-modal.tsx index 31c437b1..1e2c1482 100644 --- a/web/components/editor/market-modal.tsx +++ b/web/components/editor/market-modal.tsx @@ -1,12 +1,6 @@ import { Editor } from '@tiptap/react' import { Contract } from 'common/contract' -import { useState } from 'react' -import { Button } from '../button' -import { ContractSearch } from '../contract-search' -import { Col } from '../layout/col' -import { Modal } from '../layout/modal' -import { Row } from '../layout/row' -import { LoadingIndicator } from '../loading-indicator' +import { SelectMarketsModal } from '../contract-select-modal' import { embedContractCode, embedContractGridCode } from '../share-embed-button' import { insertContent } from './utils' @@ -17,83 +11,23 @@ export function MarketModal(props: { }) { const { editor, open, setOpen } = props - const [contracts, setContracts] = useState([]) - const [loading, setLoading] = useState(false) - - async function addContract(contract: Contract) { - if (contracts.map((c) => c.id).includes(contract.id)) { - setContracts(contracts.filter((c) => c.id !== contract.id)) - } else setContracts([...contracts, contract]) - } - - async function doneAddingContracts() { - setLoading(true) + function onSubmit(contracts: Contract[]) { if (contracts.length == 1) { insertContent(editor, embedContractCode(contracts[0])) } else if (contracts.length > 1) { insertContent(editor, embedContractGridCode(contracts)) } - setLoading(false) - setOpen(false) - setContracts([]) } return ( - -
- -
Embed a market
- - {!loading && ( - - {contracts.length == 1 && ( - - )} - {contracts.length > 1 && ( - - )} - - - )} -
- - {loading && ( -
- -
- )} - -
- c.id), - highlightClassName: - '!bg-indigo-100 outline outline-2 outline-indigo-300', - }} - additionalFilter={{}} /* hide pills */ - headerClassName="bg-white" - /> -
- - + + len == 1 ? 'Embed 1 question' : `Embed grid of ${len} questions` + } + onSubmit={onSubmit} + /> ) } diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index cf444061..def97801 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -1,8 +1,7 @@ import dayjs from 'dayjs' import { Contract } from 'common/contract' import { Bet } from 'common/bet' -import { User } from 'common/user' -import { useUser, useUserById } from 'web/hooks/use-user' +import { useUser } from 'web/hooks/use-user' import { Row } from 'web/components/layout/row' import { Avatar, EmptyAvatar } from 'web/components/avatar' import clsx from 'clsx' @@ -18,29 +17,20 @@ import { UserLink } from 'web/components/user-link' export function FeedBet(props: { contract: Contract; bet: Bet }) { const { contract, bet } = props - const { userId, createdTime } = bet - - const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01') - // eslint-disable-next-line react-hooks/rules-of-hooks - const bettor = isBeforeJune2022 ? undefined : useUserById(userId) - - const user = useUser() - const isSelf = user?.id === userId + const { userAvatarUrl, userUsername, createdTime } = bet + const showUser = dayjs(createdTime).isAfter('2022-06-01') return ( - {isSelf ? ( - - ) : bettor ? ( - + {showUser ? ( + ) : ( )} @@ -50,13 +40,13 @@ export function FeedBet(props: { contract: Contract; bet: Bet }) { export function BetStatusText(props: { contract: Contract bet: Bet - isSelf: boolean - bettor?: User + hideUser?: boolean hideOutcome?: boolean className?: string }) { - const { bet, contract, bettor, isSelf, hideOutcome, className } = props + const { bet, contract, hideUser, hideOutcome, className } = props const { outcomeType } = contract + const self = useUser() const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isFreeResponse = outcomeType === 'FREE_RESPONSE' const { amount, outcome, createdTime, challengeSlug } = bet @@ -101,10 +91,10 @@ export function BetStatusText(props: { return (
- {bettor ? ( - + {!hideUser ? ( + ) : ( - {isSelf ? 'You' : 'A trader'} + {self?.id === bet.userId ? 'You' : 'A trader'} )}{' '} {bought} {money} {outOfTotalAmount} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index a3e9f35a..f896ddb5 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -14,9 +14,7 @@ import { OutcomeLabel } from 'web/components/outcome-label' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { firebaseLogin } from 'web/lib/firebase/users' import { createCommentOnContract } from 'web/lib/firebase/comments' -import { BetStatusText } from 'web/components/feed/feed-bets' import { Col } from 'web/components/layout/col' -import { getProbability } from 'common/calculate' import { track } from 'web/lib/service/analytics' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' @@ -301,74 +299,14 @@ export function ContractCommentInput(props: { const { id } = mostRecentCommentableBet || { id: undefined } return ( -
- - - - ) -} - -function CommentBetArea(props: { - betsByCurrentUser: Bet[] - contract: Contract - commentsByCurrentUser: ContractComment[] - parentAnswerOutcome?: string - user?: User | null - className?: string - mostRecentCommentableBet?: Bet -}) { - const { betsByCurrentUser, contract, user, mostRecentCommentableBet } = props - - const { userPosition, outcome } = getBettorsLargestPositionBeforeTime( - contract, - Date.now(), - betsByCurrentUser - ) - - const isNumeric = contract.outcomeType === 'NUMERIC' - - return ( - -
- {mostRecentCommentableBet && ( - - )} - {!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && ( - <> - {"You're"} - - - )} -
-
+ ) } diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx index e2a80624..8f8faf9b 100644 --- a/web/components/feed/feed-liquidity.tsx +++ b/web/components/feed/feed-liquidity.tsx @@ -9,13 +9,17 @@ import { RelativeTimestamp } from 'web/components/relative-timestamp' import React from 'react' import { LiquidityProvision } from 'common/liquidity-provision' import { UserLink } from 'web/components/user-link' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from 'common/antes' export function FeedLiquidity(props: { className?: string liquidity: LiquidityProvision }) { const { liquidity } = props - const { userId, createdTime } = liquidity + const { userId, createdTime, isAnte } = liquidity const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01') // eslint-disable-next-line react-hooks/rules-of-hooks @@ -24,8 +28,15 @@ export function FeedLiquidity(props: { const user = useUser() const isSelf = user?.id === userId + if ( + isAnte || + userId === HOUSE_LIQUIDITY_PROVIDER_ID || + userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID + ) + return <> + return ( - + {isSelf ? ( ) : bettor ? ( diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 332b044a..1dd261cb 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -11,7 +11,7 @@ import { User } from 'common/user' import { useContractFollows } from 'web/hooks/use-follows' import { firebaseLogin, updateUser } from 'web/lib/firebase/users' import { track } from 'web/lib/service/analytics' -import { FollowMarketModal } from 'web/components/contract/follow-market-modal' +import { WatchMarketModal } from 'web/components/contract/watch-market-modal' import { useState } from 'react' import { Col } from 'web/components/layout/col' @@ -65,7 +65,7 @@ export const FollowMarketButton = (props: { Watch )} -
- {!isYou && ( )} diff --git a/web/components/market-intro-panel.tsx b/web/components/market-intro-panel.tsx new file mode 100644 index 00000000..11bdf1df --- /dev/null +++ b/web/components/market-intro-panel.tsx @@ -0,0 +1,27 @@ +import Image from 'next/future/image' + +import { Col } from './layout/col' +import { BetSignUpPrompt } from './sign-up-prompt' + +export function MarketIntroPanel() { + return ( + +
Play-money predictions
+ + + +
+ Manifold Markets is a play-money prediction market platform where you + can forecast anything. +
+ + + + ) +} diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx new file mode 100644 index 00000000..d18896bd --- /dev/null +++ b/web/components/notification-settings.tsx @@ -0,0 +1,350 @@ +import React, { memo, ReactNode, useEffect, useState } from 'react' +import { Row } from 'web/components/layout/row' +import clsx from 'clsx' +import { + notification_subscription_types, + notification_destination_types, + PrivateUser, +} from 'common/user' +import { updatePrivateUser } from 'web/lib/firebase/users' +import { Col } from 'web/components/layout/col' +import { + CashIcon, + ChatIcon, + ChevronDownIcon, + ChevronUpIcon, + CurrencyDollarIcon, + InboxInIcon, + InformationCircleIcon, + LightBulbIcon, + TrendingUpIcon, + UserIcon, + UsersIcon, +} from '@heroicons/react/outline' +import { WatchMarketModal } from 'web/components/contract/watch-market-modal' +import toast from 'react-hot-toast' +import { SwitchSetting } from 'web/components/switch-setting' +import { uniq } from 'lodash' +import { + storageStore, + usePersistentState, +} from 'web/hooks/use-persistent-state' +import { safeLocalStorage } from 'web/lib/util/local' + +export function NotificationSettings(props: { + navigateToSection: string | undefined + privateUser: PrivateUser +}) { + const { navigateToSection, privateUser } = props + const [showWatchModal, setShowWatchModal] = useState(false) + + const emailsEnabled: Array = [ + 'all_comments_on_watched_markets', + 'all_replies_to_my_comments_on_watched_markets', + 'all_comments_on_contracts_with_shares_in_on_watched_markets', + + 'all_answers_on_watched_markets', + 'all_replies_to_my_answers_on_watched_markets', + 'all_answers_on_contracts_with_shares_in_on_watched_markets', + + 'your_contract_closed', + 'all_comments_on_my_markets', + 'all_answers_on_my_markets', + + 'resolutions_on_watched_markets_with_shares_in', + 'resolutions_on_watched_markets', + + 'trending_markets', + 'onboarding_flow', + 'thank_you_for_purchases', + + 'tagged_user', // missing tagged on contract description email + 'contract_from_followed_user', + 'unique_bettors_on_your_contract', + // TODO: add these + // one-click unsubscribe only unsubscribes them from that type only, (well highlighted), then a link to manage the rest of their notifications + // 'profit_loss_updates', - changes in markets you have shares in + // biggest winner, here are the rest of your markets + + // 'referral_bonuses', + // 'on_new_follow', + // 'tips_on_your_markets', + // 'tips_on_your_comments', + // maybe the following? + // 'probability_updates_on_watched_markets', + // 'limit_order_fills', + ] + const browserDisabled: Array = [ + 'trending_markets', + 'profit_loss_updates', + 'onboarding_flow', + 'thank_you_for_purchases', + ] + + type SectionData = { + label: string + subscriptionTypeToDescription: { + [key in keyof Partial]: string + } + } + + const comments: SectionData = { + label: 'New Comments', + subscriptionTypeToDescription: { + all_comments_on_watched_markets: 'All new comments', + all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, + // TODO: combine these two + all_replies_to_my_comments_on_watched_markets: + 'Only replies to your comments', + all_replies_to_my_answers_on_watched_markets: + 'Only replies to your answers', + // comments_by_followed_users_on_watched_markets: 'By followed users', + }, + } + + const answers: SectionData = { + label: 'New Answers', + subscriptionTypeToDescription: { + all_answers_on_watched_markets: 'All new answers', + all_answers_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, + // answers_by_followed_users_on_watched_markets: 'By followed users', + // answers_by_market_creator_on_watched_markets: 'By market creator', + }, + } + const updates: SectionData = { + label: 'Updates & Resolutions', + subscriptionTypeToDescription: { + market_updates_on_watched_markets: 'All creator updates', + market_updates_on_watched_markets_with_shares_in: `Only creator updates on markets you're invested in`, + resolutions_on_watched_markets: 'All market resolutions', + resolutions_on_watched_markets_with_shares_in: `Only market resolutions you're invested in`, + // probability_updates_on_watched_markets: 'Probability updates', + }, + } + const yourMarkets: SectionData = { + label: 'Markets You Created', + subscriptionTypeToDescription: { + your_contract_closed: 'Your market has closed (and needs resolution)', + all_comments_on_my_markets: 'Comments on your markets', + all_answers_on_my_markets: 'Answers on your markets', + subsidized_your_market: 'Your market was subsidized', + tips_on_your_markets: 'Likes on your markets', + }, + } + const bonuses: SectionData = { + label: 'Bonuses', + subscriptionTypeToDescription: { + betting_streaks: 'Prediction streak bonuses', + referral_bonuses: 'Referral bonuses from referring users', + unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets', + }, + } + const otherBalances: SectionData = { + label: 'Other', + subscriptionTypeToDescription: { + loan_income: 'Automatic loans from your profitable bets', + limit_order_fills: 'Limit order fills', + tips_on_your_comments: 'Tips on your comments', + }, + } + const userInteractions: SectionData = { + label: 'Users', + subscriptionTypeToDescription: { + tagged_user: 'A user tagged you', + on_new_follow: 'Someone followed you', + contract_from_followed_user: 'New markets created by users you follow', + }, + } + const generalOther: SectionData = { + label: 'Other', + subscriptionTypeToDescription: { + trending_markets: 'Weekly interesting markets', + thank_you_for_purchases: 'Thank you notes for your purchases', + onboarding_flow: 'Explanatory emails to help you get started', + // profit_loss_updates: 'Weekly profit/loss updates', + }, + } + + function NotificationSettingLine(props: { + description: string + subscriptionTypeKey: keyof notification_subscription_types + destinations: notification_destination_types[] + }) { + const { description, subscriptionTypeKey, destinations } = props + const previousInAppValue = destinations.includes('browser') + const previousEmailValue = destinations.includes('email') + const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue) + const [emailEnabled, setEmailEnabled] = useState(previousEmailValue) + const loading = 'Changing Notifications Settings' + const success = 'Changed Notification Settings!' + const highlight = navigateToSection === subscriptionTypeKey + + const changeSetting = (setting: 'browser' | 'email', newValue: boolean) => { + toast + .promise( + updatePrivateUser(privateUser.id, { + notificationPreferences: { + ...privateUser.notificationPreferences, + [subscriptionTypeKey]: destinations.includes(setting) + ? destinations.filter((d) => d !== setting) + : uniq([...destinations, setting]), + }, + }), + { + success, + loading, + error: 'Error changing notification settings. Try again?', + } + ) + .then(() => { + if (setting === 'browser') { + setInAppEnabled(newValue) + } else { + setEmailEnabled(newValue) + } + }) + } + + return ( + + + + {description} + + + {!browserDisabled.includes(subscriptionTypeKey) && ( + changeSetting('browser', newVal)} + label={'Web'} + /> + )} + {emailsEnabled.includes(subscriptionTypeKey) && ( + changeSetting('email', newVal)} + label={'Email'} + /> + )} + + + + ) + } + + const getUsersSavedPreference = ( + key: keyof notification_subscription_types + ) => { + return privateUser.notificationPreferences[key] ?? [] + } + + const Section = memo(function Section(props: { + icon: ReactNode + data: SectionData + }) { + const { icon, data } = props + const { label, subscriptionTypeToDescription } = data + const expand = + navigateToSection && + Object.keys(subscriptionTypeToDescription).includes(navigateToSection) + + // Not sure how to prevent re-render (and collapse of an open section) + // due to a private user settings change. Just going to persist expanded state here + const [expanded, setExpanded] = usePersistentState(expand ?? false, { + key: + 'NotificationsSettingsSection-' + + Object.keys(subscriptionTypeToDescription).join('-'), + store: storageStore(safeLocalStorage()), + }) + + // Not working as the default value for expanded, so using a useEffect + useEffect(() => { + if (expand) setExpanded(true) + }, [expand, setExpanded]) + + return ( + + setExpanded(!expanded)} + > + {icon} + {label} + + {expanded ? ( + + Hide + + ) : ( + + Show + + )} + + + {Object.entries(subscriptionTypeToDescription).map(([key, value]) => ( + + ))} + + + ) + }) + + return ( +
+
+ + Notifications for Watched Markets + setShowWatchModal(true)} + /> + +
} data={comments} /> +
} + data={updates} + /> +
} + data={answers} + /> +
} data={yourMarkets} /> + + Balance Changes + +
} + data={bonuses} + /> +
} + data={otherBalances} + /> + + General + +
} + data={userInteractions} + /> +
} + data={generalOther} + /> + + + + ) +} diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index 694a0193..4d1d63be 100644 --- a/web/components/profile/betting-streak-modal.tsx +++ b/web/components/profile/betting-streak-modal.tsx @@ -3,26 +3,52 @@ import { Col } from 'web/components/layout/col' import { BETTING_STREAK_BONUS_AMOUNT, BETTING_STREAK_BONUS_MAX, + BETTING_STREAK_RESET_HOUR, } from 'common/economy' import { formatMoney } from 'common/util/format' +import { User } from 'common/user' +import dayjs from 'dayjs' +import clsx from 'clsx' export function BettingStreakModal(props: { isOpen: boolean setOpen: (open: boolean) => void + currentUser?: User | null }) { - const { isOpen, setOpen } = props + const { isOpen, setOpen, currentUser } = props + const missingStreak = currentUser && !hasCompletedStreakToday(currentUser) return (
- 🔥 - Daily betting streaks + + 🔥 + + {missingStreak && ( + + + You haven't predicted yet today! + + + If the fire icon is gray, this means you haven't predicted yet + today to get your streak bonus. Get out there and make a + prediction! + + + )} + Daily prediction streaks• What are they? You get {formatMoney(BETTING_STREAK_BONUS_AMOUNT)} more for each day - of consecutive betting up to {formatMoney(BETTING_STREAK_BONUS_MAX)} - . The more days you bet in a row, the more you earn! + of consecutive predicting up to{' '} + {formatMoney(BETTING_STREAK_BONUS_MAX)}. The more days you predict + in a row, the more you earn! • Where can I check my streak? @@ -36,3 +62,17 @@ export function BettingStreakModal(props: { ) } + +export function hasCompletedStreakToday(user: User) { + const now = dayjs().utc() + const utcTodayAtResetHour = now + .hour(BETTING_STREAK_RESET_HOUR) + .minute(0) + .second(0) + const utcYesterdayAtResetHour = utcTodayAtResetHour.subtract(1, 'day') + let resetTime = utcTodayAtResetHour.valueOf() + if (now.isBefore(utcTodayAtResetHour)) { + resetTime = utcYesterdayAtResetHour.valueOf() + } + return (user?.lastBetTime ?? 0) > resetTime +} diff --git a/web/components/profile/twitch-panel.tsx b/web/components/profile/twitch-panel.tsx new file mode 100644 index 00000000..b284b242 --- /dev/null +++ b/web/components/profile/twitch-panel.tsx @@ -0,0 +1,133 @@ +import clsx from 'clsx' +import { MouseEventHandler, ReactNode, useState } from 'react' +import toast from 'react-hot-toast' + +import { LinkIcon } from '@heroicons/react/solid' +import { usePrivateUser, useUser } from 'web/hooks/use-user' +import { updatePrivateUser } from 'web/lib/firebase/users' +import { track } from 'web/lib/service/analytics' +import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' +import { copyToClipboard } from 'web/lib/util/copy' +import { Button, ColorType } from './../button' +import { Row } from './../layout/row' +import { LoadingIndicator } from './../loading-indicator' + +function BouncyButton(props: { + children: ReactNode + onClick?: MouseEventHandler + color?: ColorType +}) { + const { children, onClick, color } = props + return ( + + ) +} + +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 = setShowBettingStreakModal(true)} > 🔥 {user.currentBettingStreak ?? 0} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 473facd4..d8ce025e 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { notification_subscribe_types, PrivateUser } from 'common/user' +import { PrivateUser } from 'common/user' import { Notification } from 'common/notification' import { getNotificationsQuery } from 'web/lib/firebase/notifications' import { groupBy, map, partition } from 'lodash' @@ -23,11 +23,8 @@ function useNotifications(privateUser: PrivateUser) { if (!result.data) return undefined const notifications = result.data as Notification[] - return getAppropriateNotifications( - notifications, - privateUser.notificationPreferences - ).filter((n) => !n.isSeenOnHref) - }, [privateUser.notificationPreferences, result.data]) + return notifications.filter((n) => !n.isSeenOnHref) + }, [result.data]) return notifications } @@ -111,29 +108,3 @@ export function groupNotifications(notifications: Notification[]) { }) return notificationGroups } - -const lessPriorityReasons = [ - 'on_contract_with_users_comment', - 'on_contract_with_users_answer', - // Notifications not currently generated for users who've sold their shares - 'on_contract_with_users_shares_out', - // Not sure if users will want to see these w/ less: - // 'on_contract_with_users_shares_in', -] - -function getAppropriateNotifications( - notifications: Notification[], - notificationPreferences?: notification_subscribe_types -) { - if (notificationPreferences === 'all') return notifications - if (notificationPreferences === 'less') - return notifications.filter( - (n) => - n.reason && - // Show all contract notifications and any that aren't in the above list: - (n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason)) - ) - if (notificationPreferences === 'none') return [] - - return notifications -} diff --git a/web/lib/api/api-key.ts b/web/lib/api/api-key.ts new file mode 100644 index 00000000..1a8c84c1 --- /dev/null +++ b/web/lib/api/api-key.ts @@ -0,0 +1,9 @@ +import { updatePrivateUser } from '../firebase/users' + +export const generateNewApiKey = async (userId: string) => { + const newApiKey = crypto.randomUUID() + + return await updatePrivateUser(userId, { apiKey: newApiKey }) + .then(() => newApiKey) + .catch(() => undefined) +} diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 89607d15..adc9c918 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -25,7 +25,6 @@ import { Contract } from 'common/contract' import { getContractFromId, updateContract } from 'web/lib/firebase/contracts' import { db } from 'web/lib/firebase/init' import { filterDefined } from 'common/util/array' -import { getUser } from 'web/lib/firebase/users' export const groups = coll('groups') export const groupMembers = (groupId: string) => @@ -254,9 +253,9 @@ export function getGroupLinkToDisplay(contract: Contract) { return groupToDisplay } -export async function listMembers(group: Group) { +export async function listMemberIds(group: Group) { const members = await getValues(groupMembers(group.id)) - return await Promise.all(members.map((m) => m.userId).map(getUser)) + return members.map((m) => m.userId) } export const topFollowedGroupsQuery = query( diff --git a/web/lib/icons/bold-icon.tsx b/web/lib/icons/bold-icon.tsx new file mode 100644 index 00000000..f4fec497 --- /dev/null +++ b/web/lib/icons/bold-icon.tsx @@ -0,0 +1,20 @@ +// from Feather: https://feathericons.com/ +export default function BoldIcon(props: React.SVGProps) { + return ( + + + + + ) +} diff --git a/web/lib/icons/italic-icon.tsx b/web/lib/icons/italic-icon.tsx new file mode 100644 index 00000000..d412ed77 --- /dev/null +++ b/web/lib/icons/italic-icon.tsx @@ -0,0 +1,21 @@ +// from Feather: https://feathericons.com/ +export default function ItalicIcon(props: React.SVGProps) { + return ( + + + + + + ) +} diff --git a/web/lib/icons/link-icon.tsx b/web/lib/icons/link-icon.tsx new file mode 100644 index 00000000..6323344c --- /dev/null +++ b/web/lib/icons/link-icon.tsx @@ -0,0 +1,20 @@ +// from Feather: https://feathericons.com/ +export default function LinkIcon(props: React.SVGProps) { + return ( + + + + + ) +} diff --git a/web/lib/twitch/link-twitch-account.ts b/web/lib/twitch/link-twitch-account.ts new file mode 100644 index 00000000..36fb12b5 --- /dev/null +++ b/web/lib/twitch/link-twitch-account.ts @@ -0,0 +1,41 @@ +import { PrivateUser, User } from 'common/user' +import { generateNewApiKey } from '../api/api-key' + +const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately + +export async function initLinkTwitchAccount( + manifoldUserID: string, + manifoldUserAPIKey: string +): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> { + const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + manifoldID: manifoldUserID, + apiKey: manifoldUserAPIKey, + redirectURL: window.location.href, + }), + }) + const responseData = await response.json() + if (!response.ok) { + throw new Error(responseData.message) + } + const responseFetch = fetch( + `${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}` + ) + return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())] +} + +export async function linkTwitchAccountRedirect( + user: User, + privateUser: PrivateUser +) { + const apiKey = privateUser.apiKey ?? (await generateNewApiKey(user.id)) + if (!apiKey) throw new Error("Couldn't retrieve or create Manifold api key") + + const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey) + + window.location.href = twitchAuthURL +} diff --git a/web/pages/api/v0/twitch/save.ts b/web/pages/api/v0/twitch/save.ts new file mode 100644 index 00000000..775817e9 --- /dev/null +++ b/web/pages/api/v0/twitch/save.ts @@ -0,0 +1,23 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { + CORS_ORIGIN_MANIFOLD, + CORS_ORIGIN_LOCALHOST, +} from 'common/envs/constants' +import { applyCorsHeaders } from 'web/lib/api/cors' +import { fetchBackend, forwardResponse } from 'web/lib/api/proxy' + +export const config = { api: { bodyParser: true } } + +export default async function route(req: NextApiRequest, res: NextApiResponse) { + await applyCorsHeaders(req, res, { + origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], + methods: 'POST', + }) + try { + const backendRes = await fetchBackend(req, 'savetwitchcredentials') + await forwardResponse(res, backendRes) + } catch (err) { + console.error('Error talking to cloud function: ', err) + res.status(500).json({ message: 'Error communicating with backend.' }) + } +} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 5fb9549e..f5d1c605 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -426,7 +426,7 @@ export function NewContract(props: {
Cost {!deservesFreeMarket ? ( diff --git a/web/pages/experimental/home/edit.tsx b/web/pages/experimental/home/edit.tsx index f05e8357..8c242a34 100644 --- a/web/pages/experimental/home/edit.tsx +++ b/web/pages/experimental/home/edit.tsx @@ -47,7 +47,11 @@ function DoneButton(props: { className?: string }) { return ( - diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 768e2f82..f1521b42 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,28 +1,28 @@ import React, { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' -import { debounce, sortBy, take } from 'lodash' -import { SearchIcon } from '@heroicons/react/outline' import { toast } from 'react-hot-toast' import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Page } from 'web/components/page' -import { listAllBets } from 'web/lib/firebase/bets' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { addContractToGroup, getGroupBySlug, groupPath, joinGroup, - listMembers, + listMemberIds, updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group' -import { scoreCreators, scoreTraders } from 'common/scoring' +import { + useGroup, + useGroupContractIds, + useMemberIds, +} from 'web/hooks/use-group' import { Leaderboard } from 'web/components/leaderboard' import { formatMoney } from 'common/util/format' import { EditGroupButton } from 'web/components/groups/edit-group-button' @@ -31,13 +31,9 @@ import { SEO } from 'web/components/SEO' import { Linkify } from 'web/components/linkify' import { fromPropz, usePropz } from 'web/hooks/use-propz' import { Tabs } from 'web/components/layout/tabs' -import { LoadingIndicator } from 'web/components/loading-indicator' -import { Modal } from 'web/components/layout/modal' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ContractSearch } from 'web/components/contract-search' -import { FollowList } from 'web/components/follow-list' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' -import { searchInAny } from 'common/util/parse' import { CopyLinkButton } from 'web/components/copy-link-button' import { ENV_CONFIG } from 'common/envs/constants' import { useSaveReferral } from 'web/hooks/use-save-referral' @@ -53,13 +49,14 @@ import { Spacer } from 'web/components/layout/spacer' import { usePost } from 'web/hooks/use-post' import { useAdmin } from 'web/hooks/use-admin' import { track } from '@amplitude/analytics-browser' +import { SelectMarketsModal } from 'web/components/contract-select-modal' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { const { slugs } = props.params const group = await getGroupBySlug(slugs[0]) - const members = group && (await listMembers(group)) + const memberIds = group && (await listMemberIds(group)) const creatorPromise = group ? getUser(group.creatorId) : null const contracts = @@ -71,33 +68,24 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { : 'open' const aboutPost = group && group.aboutPostId != null && (await getPost(group.aboutPostId)) - const bets = await Promise.all( - contracts.map((contract: Contract) => listAllBets(contract.id)) - ) const messages = group && (await listAllCommentsOnGroup(group.id)) - const creatorScores = scoreCreators(contracts) - const traderScores = scoreTraders(contracts, bets) - const [topCreators, topTraders] = - (members && [ - toTopUsers(creatorScores, members), - toTopUsers(traderScores, members), - ]) ?? - [] + const cachedTopTraderIds = + (group && group.cachedLeaderboard?.topTraders) ?? [] + const cachedTopCreatorIds = + (group && group.cachedLeaderboard?.topCreators) ?? [] + const topTraders = await toTopUsers(cachedTopTraderIds) + + const topCreators = await toTopUsers(cachedTopCreatorIds) const creator = await creatorPromise - // Only count unresolved markets - const contractsCount = contracts.filter((c) => !c.isResolved).length return { props: { - contractsCount, group, - members, + memberIds, creator, - traderScores, topTraders, - creatorScores, topCreators, messages, aboutPost, @@ -107,19 +95,6 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { revalidate: 60, // regenerate after a minute } } - -function toTopUsers(userScores: { [userId: string]: number }, users: User[]) { - const topUserPairs = take( - sortBy(Object.entries(userScores), ([_, score]) => -1 * score), - 10 - ).filter(([_, score]) => score >= 0.5) - - const topUsers = topUserPairs.map( - ([userId]) => users.filter((user) => user.id === userId)[0] - ) - return topUsers.filter((user) => user) -} - export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } @@ -132,39 +107,25 @@ const groupSubpages = [ ] as const export default function GroupPage(props: { - contractsCount: number group: Group | null - members: User[] + memberIds: string[] creator: User - traderScores: { [userId: string]: number } - topTraders: User[] - creatorScores: { [userId: string]: number } - topCreators: User[] + topTraders: { user: User; score: number }[] + topCreators: { user: User; score: number }[] messages: GroupComment[] aboutPost: Post suggestedFilter: 'open' | 'all' }) { props = usePropz(props, getStaticPropz) ?? { - contractsCount: 0, group: null, - members: [], + memberIds: [], creator: null, - traderScores: {}, topTraders: [], - creatorScores: {}, topCreators: [], messages: [], suggestedFilter: 'open', } - const { - contractsCount, - creator, - traderScores, - topTraders, - creatorScores, - topCreators, - suggestedFilter, - } = props + const { creator, topTraders, topCreators, suggestedFilter } = props const router = useRouter() const { slugs } = router.query as { slugs: string[] } @@ -175,7 +136,7 @@ export default function GroupPage(props: { const user = useUser() const isAdmin = useAdmin() - const members = useMembers(group?.id) ?? props.members + const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds useSaveReferral(user, { defaultReferrerUsername: creator.username, @@ -186,18 +147,25 @@ export default function GroupPage(props: { return } const isCreator = user && group && user.id === group.creatorId - const isMember = user && members.map((m) => m.id).includes(user.id) + const isMember = user && memberIds.includes(user.id) + const maxLeaderboardSize = 50 const leaderboard = (
- +
+ + +
) @@ -216,7 +184,7 @@ export default function GroupPage(props: { creator={creator} isCreator={!!isCreator} user={user} - members={members} + memberIds={memberIds} /> ) @@ -233,7 +201,6 @@ export default function GroupPage(props: { const tabs = [ { - badge: `${contractsCount}`, title: 'Markets', content: questionsTab, href: groupPath(group.slug, 'markets'), @@ -312,9 +279,9 @@ function GroupOverview(props: { creator: User user: User | null | undefined isCreator: boolean - members: User[] + memberIds: string[] }) { - const { group, creator, isCreator, user, members } = props + const { group, creator, isCreator, user, memberIds } = props const anyoneCanJoinChoices: { [key: string]: string } = { Closed: 'false', Open: 'true', @@ -333,7 +300,7 @@ function GroupOverview(props: { const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( group.slug )}${postFix}` - const isMember = user ? members.map((m) => m.id).includes(user.id) : false + const isMember = user ? memberIds.includes(user.id) : false return ( <> @@ -399,179 +366,46 @@ function GroupOverview(props: { /> )} - -
-
Members
- - ) } -function SearchBar(props: { setQuery: (query: string) => void }) { - const { setQuery } = props - const debouncedQuery = debounce(setQuery, 50) - return ( -
- - debouncedQuery(e.target.value)} - placeholder="Find a member" - className="input input-bordered mb-4 w-full pl-12" - /> -
- ) -} - -function GroupMemberSearch(props: { members: User[]; group: Group }) { - const [query, setQuery] = useState('') - const { group } = props - let { members } = props - - // Use static members on load, but also listen to member changes: - const listenToMembers = useMembers(group.id) - if (listenToMembers) { - members = listenToMembers - } - - // TODO use find-active-contracts to sort by? - const matches = sortBy(members, [(member) => member.name]).filter((m) => - searchInAny(query, m.name, m.username) - ) - const matchLimit = 25 - - return ( -
- -
- {matches.length > 0 && ( - m.id)} /> - )} - {matches.length > 25 && ( -
- And {matches.length - matchLimit} more... -
- )} - - - ) -} - -function SortedLeaderboard(props: { - users: User[] - scoreFunction: (user: User) => number +function GroupLeaderboard(props: { + topUsers: { user: User; score: number }[] title: string + maxToShow: number header: string - maxToShow?: number }) { - const { users, scoreFunction, title, header, maxToShow } = props - const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a)) + const { topUsers, title, maxToShow, header } = props + + const scoresByUser = topUsers.reduce((acc, { user, score }) => { + acc[user.id] = score + return acc + }, {} as { [key: string]: number }) + return ( t.user)} title={title} columns={[ - { header, renderCell: (user) => formatMoney(scoreFunction(user)) }, + { header, renderCell: (user) => formatMoney(scoresByUser[user.id]) }, ]} maxToShow={maxToShow} /> ) } -function GroupLeaderboards(props: { - traderScores: { [userId: string]: number } - creatorScores: { [userId: string]: number } - topTraders: User[] - topCreators: User[] - members: User[] - user: User | null | undefined -}) { - const { traderScores, creatorScores, members, topTraders, topCreators } = - props - const maxToShow = 50 - // Consider hiding M$0 - // If it's just one member (curator), show all bettors, otherwise just show members - return ( -
-
- {members.length > 1 ? ( - <> - traderScores[user.id] ?? 0} - title="🏅 Top traders" - header="Profit" - maxToShow={maxToShow} - /> - creatorScores[user.id] ?? 0} - title="🏅 Top creators" - header="Market volume" - maxToShow={maxToShow} - /> - - ) : ( - <> - formatMoney(traderScores[user.id] ?? 0), - }, - ]} - maxToShow={maxToShow} - /> - - formatMoney(creatorScores[user.id] ?? 0), - }, - ]} - maxToShow={maxToShow} - /> - - )} -
- - ) -} - function AddContractButton(props: { group: Group; user: User }) { const { group, user } = props const [open, setOpen] = useState(false) - const [contracts, setContracts] = useState([]) - const [loading, setLoading] = useState(false) const groupContractIds = useGroupContractIds(group.id) - async function addContractToCurrentGroup(contract: Contract) { - if (contracts.map((c) => c.id).includes(contract.id)) { - setContracts(contracts.filter((c) => c.id !== contract.id)) - } else setContracts([...contracts, contract]) - } - - async function doneAddingContracts() { - Promise.all( - contracts.map(async (contract) => { - setLoading(true) - await addContractToGroup(group, contract, user.id) - }) - ).then(() => { - setLoading(false) - setOpen(false) - setContracts([]) - }) + async function onSubmit(contracts: Contract[]) { + await Promise.all( + contracts.map((contract) => addContractToGroup(group, contract, user.id)) + ) } return ( @@ -587,71 +421,27 @@ function AddContractButton(props: { group: Group; user: User }) { - - - -
Add markets
- -
- Add pre-existing markets to this group, or{' '} - - - create a new one - - - . -
- - {contracts.length > 0 && ( - - {!loading ? ( - - - - - ) : ( - - - - )} - - )} - - -
- c.id), - highlightClassName: '!bg-indigo-100 border-indigo-100 border-2', - }} - /> + title="Add markets" + description={ +
+ Add pre-existing markets to this group, or{' '} + + + create a new one + + + .
- - + } + submitLabel={(len) => `Add ${len} question${len !== 1 ? 's' : ''}`} + onSubmit={onSubmit} + contractSearchOptions={{ + additionalFilter: { excludeContractIds: groupContractIds }, + }} + /> ) } @@ -684,3 +474,15 @@ function JoinGroupButton(props: {
) } + +const toTopUsers = async ( + cachedUserIds: { userId: string; score: number }[] +): Promise<{ user: User; score: number }[]> => + ( + await Promise.all( + cachedUserIds.map(async (e) => { + const user = await getUser(e.userId) + return { user, score: e.score ?? 0 } + }) + ) + ).filter((e) => e.user != null) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index d10812bf..008f5df1 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,6 +1,6 @@ -import { Tabs } from 'web/components/layout/tabs' +import { ControlledTabs } from 'web/components/layout/tabs' import React, { useEffect, useMemo, useState } from 'react' -import Router from 'next/router' +import Router, { useRouter } from 'next/router' import { Notification, notification_source_types } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' @@ -26,6 +26,7 @@ import { import { NotificationGroup, useGroupedNotifications, + useUnseenGroupedNotification, } from 'web/hooks/use-notifications' import { TrendingUpIcon } from '@heroicons/react/outline' import { formatMoney } from 'common/util/format' @@ -40,7 +41,7 @@ import { Pagination } from 'web/components/pagination' import { useWindowSize } from 'web/hooks/use-window-size' import { safeLocalStorage } from 'web/lib/util/local' import { SiteLink } from 'web/components/site-link' -import { NotificationSettings } from 'web/components/NotificationSettings' +import { NotificationSettings } from 'web/components/notification-settings' import { SEO } from 'web/components/SEO' import { usePrivateUser, useUser } from 'web/hooks/use-user' import { UserLink } from 'web/components/user-link' @@ -56,24 +57,51 @@ const HIGHLIGHT_CLASS = 'bg-indigo-50' export default function Notifications() { const privateUser = usePrivateUser() + const router = useRouter() + const [navigateToSection, setNavigateToSection] = useState() + const [activeIndex, setActiveIndex] = useState(0) useEffect(() => { if (privateUser === null) Router.push('/') }) + useEffect(() => { + const query = { ...router.query } + if (query.tab === 'settings') { + setActiveIndex(1) + } + if (query.section) { + setNavigateToSection(query.section as string) + } + }, [router.query]) + return (
<SEO title="Notifications" description="Manifold user notifications" /> - {privateUser && ( + {privateUser && router.isReady && ( <div> - <Tabs + <ControlledTabs currentPageForAnalytics={'notifications'} labelClassName={'pb-2 pt-1 '} className={'mb-0 sm:mb-2'} - defaultIndex={0} + activeIndex={activeIndex} + onClick={(title, i) => { + router.replace( + { + query: { + ...router.query, + tab: title.toLowerCase(), + section: '', + }, + }, + undefined, + { shallow: true } + ) + setActiveIndex(i) + }} tabs={[ { title: 'Notifications', @@ -82,9 +110,10 @@ export default function Notifications() { { title: 'Settings', content: ( - <div className={''}> - <NotificationSettings /> - </div> + <NotificationSettings + navigateToSection={navigateToSection} + privateUser={privateUser} + /> ), }, ]} @@ -128,16 +157,13 @@ function NotificationsList(props: { privateUser: PrivateUser }) { const { privateUser } = props const [page, setPage] = useState(0) const allGroupedNotifications = useGroupedNotifications(privateUser) + const unseenGroupedNotifications = useUnseenGroupedNotification(privateUser) const paginatedGroupedNotifications = useMemo(() => { if (!allGroupedNotifications) return const start = page * NOTIFICATIONS_PER_PAGE const end = start + NOTIFICATIONS_PER_PAGE const maxNotificationsToShow = allGroupedNotifications.slice(start, end) - const remainingNotification = allGroupedNotifications.slice(end) - for (const notification of remainingNotification) { - if (notification.isSeen) break - else setNotificationsAsSeen(notification.notifications) - } + const local = safeLocalStorage() local?.setItem( 'notification-groups', @@ -146,6 +172,19 @@ function NotificationsList(props: { privateUser: PrivateUser }) { return maxNotificationsToShow }, [allGroupedNotifications, page]) + // Set all notifications that don't fit on the first page to seen + useEffect(() => { + if ( + paginatedGroupedNotifications && + paginatedGroupedNotifications?.length >= NOTIFICATIONS_PER_PAGE + ) { + const allUnseenNotifications = unseenGroupedNotifications + ?.map((ng) => ng.notifications) + .flat() + allUnseenNotifications && setNotificationsAsSeen(allUnseenNotifications) + } + }, [paginatedGroupedNotifications, unseenGroupedNotifications]) + if (!paginatedGroupedNotifications || !allGroupedNotifications) return <LoadingIndicator /> @@ -259,7 +298,7 @@ function IncomeNotificationGroupItem(props: { ...notificationsForSourceTitle[0], sourceText: sum.toString(), sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername, - data: JSON.stringify(uniqueUsers), + data: { uniqueUsers }, } newNotifications.push(newNotification) } @@ -376,7 +415,7 @@ function IncomeNotificationItem(props: { const isTip = sourceType === 'tip' || sourceType === 'tip_and_like' const isUniqueBettorBonus = sourceType === 'bonus' const userLinks: MultiUserLinkInfo[] = - isTip || isUniqueBettorBonus ? JSON.parse(data ?? '{}') : [] + isTip || isUniqueBettorBonus ? data?.uniqueUsers ?? [] : [] useEffect(() => { setNotificationsAsSeen([notification]) @@ -390,7 +429,7 @@ function IncomeNotificationItem(props: { reasonText = !simple ? `Bonus for ${ parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT - } new traders on` + } new predictors on` : 'bonus on' } else if (sourceType === 'tip') { reasonText = !simple ? `tipped you on` : `in tips on` @@ -398,19 +437,22 @@ function IncomeNotificationItem(props: { if (sourceText && +sourceText === 50) reasonText = '(max) for your' else reasonText = 'for your' } else if (sourceType === 'loan' && sourceText) { - reasonText = `of your invested bets returned as a` + reasonText = `of your invested predictions returned as a` // TODO: support just 'like' notification without a tip } else if (sourceType === 'tip_and_like' && sourceText) { reasonText = !simple ? `liked` : `in likes on` } - const streakInDays = - Date.now() - notification.createdTime > 24 * 60 * 60 * 1000 - ? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT - : user?.currentBettingStreak ?? 0 + const streakInDays = notification.data?.streak + ? notification.data?.streak + : Date.now() - notification.createdTime > 24 * 60 * 60 * 1000 + ? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT + : user?.currentBettingStreak ?? 0 const bettingStreakText = sourceType === 'betting_streak_bonus' && - (sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak') + (sourceText + ? `🔥 ${streakInDays} day Prediction Streak` + : 'Prediction Streak') return ( <> @@ -508,7 +550,7 @@ function IncomeNotificationItem(props: { {(isTip || isUniqueBettorBonus) && ( <MultiUserTransactionLink userInfos={userLinks} - modalLabel={isTip ? 'Who tipped you' : 'Unique traders'} + modalLabel={isTip ? 'Who tipped you' : 'Unique predictors'} /> )} <Row className={'line-clamp-2 flex max-w-xl'}> @@ -992,51 +1034,54 @@ function getReasonForShowingNotification( ) { const { sourceType, sourceUpdateType, reason, sourceSlug } = notification let reasonText: string - switch (sourceType) { - case 'comment': - if (reason === 'reply_to_users_answer') - reasonText = justSummary ? 'replied' : 'replied to you on' - else if (reason === 'tagged_user') - reasonText = justSummary ? 'tagged you' : 'tagged you on' - else if (reason === 'reply_to_users_comment') - reasonText = justSummary ? 'replied' : 'replied to you on' - else reasonText = justSummary ? `commented` : `commented on` - break - case 'contract': - if (reason === 'you_follow_user') - reasonText = justSummary ? 'asked the question' : 'asked' - else if (sourceUpdateType === 'resolved') - reasonText = justSummary ? `resolved the question` : `resolved` - else if (sourceUpdateType === 'closed') reasonText = `Please resolve` - else reasonText = justSummary ? 'updated the question' : `updated` - break - case 'answer': - if (reason === 'on_users_contract') reasonText = `answered your question ` - else reasonText = `answered` - break - case 'follow': - reasonText = 'followed you' - break - case 'liquidity': - reasonText = 'added a subsidy to your question' - break - case 'group': - reasonText = 'added you to the group' - break - case 'user': - if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') - reasonText = 'joined to bet on your market' - else if (sourceSlug) reasonText = 'joined because you shared' - else reasonText = 'joined because of you' - break - case 'bet': - reasonText = 'bet against you' - break - case 'challenge': - reasonText = 'accepted your challenge' - break - default: - reasonText = '' - } + // TODO: we could leave out this switch and just use the reason field now that they have more information + if (reason === 'tagged_user') + reasonText = justSummary ? 'tagged you' : 'tagged you on' + else + switch (sourceType) { + case 'comment': + if (reason === 'reply_to_users_answer') + reasonText = justSummary ? 'replied' : 'replied to you on' + else if (reason === 'reply_to_users_comment') + reasonText = justSummary ? 'replied' : 'replied to you on' + else reasonText = justSummary ? `commented` : `commented on` + break + case 'contract': + if (reason === 'contract_from_followed_user') + reasonText = justSummary ? 'asked the question' : 'asked' + else if (sourceUpdateType === 'resolved') + reasonText = justSummary ? `resolved the question` : `resolved` + else if (sourceUpdateType === 'closed') reasonText = `Please resolve` + else reasonText = justSummary ? 'updated the question' : `updated` + break + case 'answer': + if (reason === 'answer_on_your_contract') + reasonText = `answered your question ` + else reasonText = `answered` + break + case 'follow': + reasonText = 'followed you' + break + case 'liquidity': + reasonText = 'added a subsidy to your question' + break + case 'group': + reasonText = 'added you to the group' + break + case 'user': + if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') + reasonText = 'joined to bet on your market' + else if (sourceSlug) reasonText = 'joined because you shared' + else reasonText = 'joined because of you' + break + case 'bet': + reasonText = 'bet against you' + break + case 'challenge': + reasonText = 'accepted your challenge' + break + default: + reasonText = '' + } return reasonText } diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index 240fe8fa..6b70b5d2 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -12,15 +12,13 @@ import { uploadImage } from 'web/lib/firebase/storage' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { User, PrivateUser } from 'common/user' -import { - getUserAndPrivateUser, - updateUser, - updatePrivateUser, -} from 'web/lib/firebase/users' +import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users' import { defaultBannerUrl } from 'web/components/user-page' import { SiteLink } from 'web/components/site-link' import Textarea from 'react-expanding-textarea' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' +import { generateNewApiKey } from 'web/lib/api/api-key' +import { TwitchPanel } from 'web/components/profile/twitch-panel' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { return { props: { auth: await getUserAndPrivateUser(creds.uid) } } @@ -96,11 +94,8 @@ export default function ProfilePage(props: { } const updateApiKey = async (e: React.MouseEvent) => { - const newApiKey = crypto.randomUUID() - setApiKey(newApiKey) - await updatePrivateUser(user.id, { apiKey: newApiKey }).catch(() => { - setApiKey(privateUser.apiKey || '') - }) + const newApiKey = await generateNewApiKey(user.id) + setApiKey(newApiKey ?? '') e.preventDefault() } @@ -242,6 +237,8 @@ export default function ProfilePage(props: { </button> </div> </div> + + <TwitchPanel /> </Col> </Col> </Page> diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index 27c51c15..e81c239f 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -155,7 +155,7 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) { <Page> <SEO title="Tournaments" - description="Win money by betting in forecasting touraments on current events, sports, science, and more" + description="Win money by predicting in forecasting tournaments on current events, sports, science, and more" /> <Col className="m-4 gap-10 sm:mx-10 sm:gap-24 xl:w-[125%]"> {sections.map( diff --git a/web/pages/twitch.tsx b/web/pages/twitch.tsx new file mode 100644 index 00000000..7ca892e8 --- /dev/null +++ b/web/pages/twitch.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react' + +import { Page } from 'web/components/page' +import { Col } from 'web/components/layout/col' +import { ManifoldLogo } from 'web/components/nav/manifold-logo' +import { useSaveReferral } from 'web/hooks/use-save-referral' +import { SEO } from 'web/components/SEO' +import { Spacer } from 'web/components/layout/spacer' +import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users' +import { track } from 'web/lib/service/analytics' +import { Row } from 'web/components/layout/row' +import { Button } from 'web/components/button' +import { useTracking } from 'web/hooks/use-tracking' +import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' +import { usePrivateUser, useUser } from 'web/hooks/use-user' +import { LoadingIndicator } from 'web/components/loading-indicator' +import toast from 'react-hot-toast' + +export default function TwitchLandingPage() { + useSaveReferral() + useTracking('view twitch landing page') + + const user = useUser() + const privateUser = usePrivateUser() + const twitchUser = privateUser?.twitchInfo?.twitchName + + const callback = + user && privateUser + ? () => linkTwitchAccountRedirect(user, privateUser) + : async () => { + const result = await firebaseLogin() + + const userId = result.user.uid + const { user, privateUser } = await getUserAndPrivateUser(userId) + if (!user || !privateUser) return + + await linkTwitchAccountRedirect(user, privateUser) + } + + const [isLoading, setLoading] = useState(false) + + const getStarted = async () => { + try { + setLoading(true) + + const promise = callback() + track('twitch page button click') + await promise + } catch (e) { + console.error(e) + toast.error('Failed to sign up. Please try again later.') + setLoading(false) + } + } + + return ( + <Page> + <SEO + title="Manifold Markets on Twitch" + description="Get more out of Twitch with play-money betting markets." + /> + <div className="px-4 pt-2 md:mt-0 lg:hidden"> + <ManifoldLogo /> + </div> + <Col className="items-center"> + <Col className="max-w-3xl"> + <Col className="mb-6 rounded-xl sm:m-12 sm:mt-0"> + <Row className="self-center"> + <img height={200} width={200} src="/twitch-logo.png" /> + <img height={200} width={200} src="/flappy-logo.gif" /> + </Row> + <div className="m-4 max-w-[550px] self-center"> + <h1 className="text-3xl sm:text-6xl xl:text-6xl"> + <div className="font-semibold sm:mb-2"> + <span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent"> + Bet + </span>{' '} + on your favorite streams + </div> + </h1> + <Spacer h={6} /> + <div className="mb-4 px-2 "> + Get more out of Twitch with play-money betting markets.{' '} + {!twitchUser && + 'Click the button below to link your Twitch account.'} + <br /> + </div> + </div> + + <Spacer h={6} /> + + {twitchUser ? ( + <div className="mt-3 self-center rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 "> + <div className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6"> + <div className="truncate text-sm font-medium text-gray-500"> + Twitch account linked + </div> + <div className="mt-1 text-2xl font-semibold text-gray-900"> + {twitchUser} + </div> + </div> + </div> + ) : isLoading ? ( + <LoadingIndicator spinnerClassName="!w-16 !h-16" /> + ) : ( + <Button + size="2xl" + color="gradient" + className="self-center" + onClick={getStarted} + > + Get started + </Button> + )} + </Col> + </Col> + </Col> + </Page> + ) +} diff --git a/web/public/twitch-logo.png b/web/public/twitch-logo.png new file mode 100644 index 00000000..7f575e7a Binary files /dev/null and b/web/public/twitch-logo.png differ
+ +
+ + + + + + +
+ + + +
+
+ + + +
+ +
+ + - - - -
- + - -
+ - - - + " target="_blank">click here to manage your notifications. +

+ + + + + +
-
+
-

- This e-mail has been sent to {{name}}, - +

+ This e-mail has been sent to {{name}}, + click here to unsubscribe. -

-
-
+
+
+
- - -
-
- - - - +
+ + +
+
+ + + + + \ No newline at end of file diff --git a/functions/src/email-templates/thank-you.html b/functions/src/email-templates/thank-you.html index c4ad7baa..7ac72d0a 100644 --- a/functions/src/email-templates/thank-you.html +++ b/functions/src/email-templates/thank-you.html @@ -214,10 +214,12 @@

This e-mail has been sent - to {{name}}, click here to - unsubscribe.

+ to {{name}}, + click here to manage your notifications. +