diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index 493b5fa9..b5153355 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -123,6 +123,7 @@ export function calculateCpmmAmountToProb( prob: number, outcome: 'YES' | 'NO' ) { + if (prob <= 0 || prob >= 1 || isNaN(prob)) return Infinity if (outcome === 'NO') prob = 1 - prob // First, find an upper bound that leads to a more extreme probability than prob. diff --git a/common/categories.ts b/common/categories.ts index 232aa526..f302e3f2 100644 --- a/common/categories.ts +++ b/common/categories.ts @@ -1,6 +1,7 @@ import { difference } from 'lodash' export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default' + export const CATEGORIES = { politics: 'Politics', technology: 'Technology', @@ -30,10 +31,13 @@ export const EXCLUDED_CATEGORIES: category[] = [ 'manifold', 'personal', 'covid', - 'culture', 'gaming', 'crypto', - 'world', ] export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES) + +export const DEFAULT_CATEGORY_GROUPS = DEFAULT_CATEGORIES.map((c) => ({ + slug: c.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX, + name: CATEGORIES[c as category], +})) diff --git a/common/contract.ts b/common/contract.ts index 5ddcf0b8..177af862 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -1,6 +1,7 @@ import { Answer } from './answer' import { Fees } from './fees' import { JSONContent } from '@tiptap/core' +import { GroupLink } from 'common/group' export type AnyMechanism = DPM | CPMM export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric @@ -46,8 +47,10 @@ export type Contract = { collectedFees: Fees groupSlugs?: string[] + groupLinks?: GroupLink[] uniqueBettorIds?: string[] uniqueBettorCount?: number + popularityScore?: number } & T export type BinaryContract = Contract & Binary diff --git a/common/envs/prod.ts b/common/envs/prod.ts index a5506b6e..2cad4203 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -23,6 +23,7 @@ export type EnvConfig = { // Currency controls fixedAnte?: number startingBalance?: number + referralBonus?: number } type FirebaseConfig = { diff --git a/common/group.ts b/common/group.ts index 15348d5a..7d3215ae 100644 --- a/common/group.ts +++ b/common/group.ts @@ -11,8 +11,19 @@ export type Group = { contractIds: string[] chatDisabled?: boolean + mostRecentChatActivityTime?: number + mostRecentContractAddedTime?: number } export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome'] +export const GROUP_CHAT_SLUG = 'chat' + +export type GroupLink = { + slug: string + name: string + groupId: string + createdTime: number + userId?: string +} diff --git a/common/new-bet.ts b/common/new-bet.ts index f484b9f7..1f5c0340 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -1,4 +1,4 @@ -import { sortBy, sumBy } from 'lodash' +import { sortBy, sum, sumBy } from 'lodash' import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet' import { @@ -142,6 +142,13 @@ export const computeFills = ( limitProb: number | undefined, unfilledBets: LimitBet[] ) => { + if (isNaN(betAmount)) { + throw new Error('Invalid bet amount: ${betAmount}') + } + if (isNaN(limitProb ?? 0)) { + throw new Error('Invalid limitProb: ${limitProb}') + } + const sortedBets = sortBy( unfilledBets.filter((bet) => bet.outcome !== outcome), (bet) => (outcome === 'YES' ? bet.limitProb : -bet.limitProb), @@ -239,6 +246,32 @@ export const getBinaryCpmmBetInfo = ( } } +export const getBinaryBetStats = ( + outcome: 'YES' | 'NO', + betAmount: number, + contract: CPMMBinaryContract | PseudoNumericContract, + limitProb: number, + unfilledBets: LimitBet[] +) => { + const { newBet } = getBinaryCpmmBetInfo( + outcome, + betAmount ?? 0, + contract, + limitProb, + unfilledBets as LimitBet[] + ) + const remainingMatched = + ((newBet.orderAmount ?? 0) - newBet.amount) / + (outcome === 'YES' ? limitProb : 1 - limitProb) + const currentPayout = newBet.shares + remainingMatched + + const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 + + const totalFees = sum(Object.values(newBet.fees)) + + return { currentPayout, currentReturn, totalFees, newBet } +} + export const getNewBinaryDpmBetInfo = ( outcome: 'YES' | 'NO', amount: number, diff --git a/common/notification.ts b/common/notification.ts index 63a44a52..5fd4236b 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -63,3 +63,4 @@ export type notification_reason_types = | 'on_group_you_are_member_of' | 'tip_received' | 'bet_fill' + | 'user_joined_from_your_group_invite' diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index 46885668..f399aa5a 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -3,4 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005 export const NUMERIC_GRAPH_COLOR = '#5fa5f9' export const NUMERIC_TEXT_COLOR = 'text-blue-500' -export const UNIQUE_BETTOR_BONUS_AMOUNT = 5 +export const UNIQUE_BETTOR_BONUS_AMOUNT = 10 diff --git a/common/pseudo-numeric.ts b/common/pseudo-numeric.ts index c99e670f..ca62a80e 100644 --- a/common/pseudo-numeric.ts +++ b/common/pseudo-numeric.ts @@ -16,8 +16,8 @@ export const getMappedValue = const { min, max, isLogScale } = contract if (isLogScale) { - const logValue = p * Math.log10(max - min) - return 10 ** logValue + min + const logValue = p * Math.log10(max - min + 1) + return 10 ** logValue + min - 1 } return p * (max - min) + min @@ -37,8 +37,11 @@ export const getPseudoProbability = ( max: number, isLogScale = false ) => { + if (value < min) return 0 + if (value > max) return 1 + if (isLogScale) { - return Math.log10(value - min) / Math.log10(max - min) + return Math.log10(value - min + 1) / Math.log10(max - min + 1) } return (value - min) / (max - min) diff --git a/common/user.ts b/common/user.ts index 477139fd..0dac5a19 100644 --- a/common/user.ts +++ b/common/user.ts @@ -38,12 +38,14 @@ export type User = { referredByUserId?: string referredByContractId?: string + referredByGroupId?: string + lastPingTime?: number } export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 // for sus users, i.e. multiple sign ups for same person export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10 -export const REFERRAL_AMOUNT = 500 +export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500 export type PrivateUser = { id: string // same as User.id username: string // denormalized from User @@ -57,7 +59,6 @@ export type PrivateUser = { initialIpAddress?: string apiKey?: string notificationPreferences?: notification_subscribe_types - lastTimeCheckedBonuses?: number } export type notification_subscribe_types = 'all' | 'less' | 'none' @@ -69,3 +70,6 @@ export type PortfolioMetrics = { timestamp: number userId: string } + +export const MANIFOLD_USERNAME = 'ManifoldMarkets' +export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' diff --git a/common/util/format.ts b/common/util/format.ts index decdd55d..4f123535 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -33,18 +33,24 @@ export function formatPercent(zeroToOne: number) { return (zeroToOne * 100).toFixed(decimalPlaces) + '%' } +const showPrecision = (x: number, sigfigs: number) => + // convert back to number for weird formatting reason + `${Number(x.toPrecision(sigfigs))}` + // Eg 1234567.89 => 1.23M; 5678 => 5.68K export function formatLargeNumber(num: number, sigfigs = 2): string { const absNum = Math.abs(num) - if (absNum < 1000) { - return '' + Number(num.toPrecision(sigfigs)) - } + if (absNum < 1) return showPrecision(num, sigfigs) + + if (absNum < 100) return showPrecision(num, 2) + if (absNum < 1000) return showPrecision(num, 3) + if (absNum < 10000) return showPrecision(num, 4) const suffix = ['', 'K', 'M', 'B', 'T', 'Q'] - const suffixIdx = Math.floor(Math.log10(absNum) / 3) - const suffixStr = suffix[suffixIdx] - const numStr = (num / Math.pow(10, 3 * suffixIdx)).toPrecision(sigfigs) - return `${Number(numStr)}${suffixStr}` + const i = Math.floor(Math.log10(absNum) / 3) + + const numStr = showPrecision(num / Math.pow(10, 3 * i), sigfigs) + return `${numStr}${suffix[i] ?? ''}` } export function toCamelCase(words: string) { diff --git a/common/util/parse.ts b/common/util/parse.ts index 94b5ab7f..cdaa6a6c 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -20,6 +20,7 @@ import { Text } from '@tiptap/extension-text' // other tiptap extensions import { Image } from '@tiptap/extension-image' import { Link } from '@tiptap/extension-link' +import Iframe from './tiptap-iframe' export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi @@ -49,6 +50,16 @@ export function parseWordsAsTags(text: string) { return parseTags(taggedText) } +// TODO: fuzzy matching +export const wordIn = (word: string, corpus: string) => + corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase()) + +const checkAgainstQuery = (query: string, corpus: string) => + query.split(' ').every((word) => wordIn(word, corpus)) + +export const searchInAny = (query: string, ...fields: string[]) => + fields.some((field) => checkAgainstQuery(query, field)) + // can't just do [StarterKit, Image...] because it doesn't work with cjs imports export const exhibitExts = [ Blockquote, @@ -70,6 +81,7 @@ export const exhibitExts = [ Image, Link, + Iframe, ] // export const exhibitExts = [StarterKit as unknown as Extension, Image] diff --git a/common/util/tiptap-iframe.ts b/common/util/tiptap-iframe.ts new file mode 100644 index 00000000..5af63d2f --- /dev/null +++ b/common/util/tiptap-iframe.ts @@ -0,0 +1,92 @@ +// Adopted from https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/iframe.ts + +import { Node } from '@tiptap/core' + +export interface IframeOptions { + allowFullscreen: boolean + HTMLAttributes: { + [key: string]: any + } +} + +declare module '@tiptap/core' { + interface Commands { + iframe: { + setIframe: (options: { src: string }) => ReturnType + } + } +} + +// These classes style the outer wrapper and the inner iframe; +// Adopted from css in https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/index.vue +const wrapperClasses = 'relative h-auto w-full overflow-hidden' +const iframeClasses = 'absolute top-0 left-0 h-full w-full' + +export default Node.create({ + name: 'iframe', + + group: 'block', + + atom: true, + + addOptions() { + return { + allowFullscreen: true, + HTMLAttributes: { + class: 'iframe-wrapper' + ' ' + wrapperClasses, + // Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in: + style: 'padding-bottom: 20rem;', + }, + } + }, + + addAttributes() { + return { + src: { + default: null, + }, + frameborder: { + default: 0, + }, + allowfullscreen: { + default: this.options.allowFullscreen, + parseHTML: () => this.options.allowFullscreen, + }, + } + }, + + parseHTML() { + return [{ tag: 'iframe' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + this.options.HTMLAttributes, + [ + 'iframe', + { + ...HTMLAttributes, + class: HTMLAttributes.class + ' ' + iframeClasses, + }, + ], + ] + }, + + addCommands() { + return { + setIframe: + (options: { src: string }) => + ({ tr, dispatch }) => { + const { selection } = tr + const node = this.type.create(options) + + if (dispatch) { + tr.replaceRangeWith(selection.from, selection.to, node) + } + + return true + }, + } + }, +}) diff --git a/docs/docs/api.md b/docs/docs/api.md index 1cea6027..667c68b8 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -34,6 +34,18 @@ response was a 4xx or 5xx.) ## Endpoints +### `GET /v0/user/[username]` + +Gets a user by their username. Remember that usernames may change. + +Requires no authorization. + +### `GET /v0/user/by-id/[id]` + +Gets a user by their unique ID. Many other API endpoints return this as the `userId`. + +Requires no authorization. + ### `GET /v0/markets` Lists all markets, ordered by creation date descending. @@ -627,6 +639,7 @@ Requires no authorization. ## Changelog +- 2022-07-15: Add user by username and user by ID APIs - 2022-06-08: Add paging to markets endpoint - 2022-06-05: Add new authorized write endpoints - 2022-02-28: Add `resolutionTime` to markets, change `closeTime` definition diff --git a/firestore.rules b/firestore.rules index bd059f6a..0f28ca80 100644 --- a/firestore.rules +++ b/firestore.rules @@ -6,7 +6,12 @@ service cloud.firestore { match /databases/{database}/documents { function isAdmin() { - return request.auth.uid == 'tUosjZRN6GRv81uRksJ67EIF0853' // James + return request.auth.token.email in [ + 'akrolsmir@gmail.com', + 'jahooma@gmail.com', + 'taowell@gmail.com', + 'manticmarkets@gmail.com' + ] } match /stats/stats { @@ -17,16 +22,17 @@ service cloud.firestore { allow read; allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']); - allow update: if resource.data.id == request.auth.uid - && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['referredByUserId']) - // only one referral allowed per user - && !("referredByUserId" in resource.data) - // user can't refer themselves - && !(resource.data.id == request.resource.data.referredByUserId); - // quid pro quos enabled (only once though so nbd) - bc I can't make this work: - // && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id); + .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime']); + // User referral rules + allow update: if resource.data.id == request.auth.uid + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['referredByUserId', 'referredByContractId', 'referredByGroupId']) + // only one referral allowed per user + && !("referredByUserId" in resource.data) + // user can't refer themselves + && !(resource.data.id == request.resource.data.referredByUserId); + // quid pro quos enabled (only once though so nbd) - bc I can't make this work: + // && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id); } match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { @@ -68,9 +74,9 @@ service cloud.firestore { match /contracts/{contractId} { allow read; allow update: if request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['tags', 'lowercaseTags', 'groupSlugs']); + .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']); allow update: if request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['description', 'closeTime']) + .hasOnly(['description', 'closeTime', 'question']) && resource.data.creatorId == request.auth.uid; allow update: if isAdmin(); match /comments/{commentId} { diff --git a/functions/README.md b/functions/README.md index 8013fb20..97a7a33b 100644 --- a/functions/README.md +++ b/functions/README.md @@ -27,6 +27,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started 1. `$ brew install java` 2. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk` + 2. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud 3. `$ gcloud config set project ` to choose the project (`$ gcloud projects list` to see options) 4. `$ mkdir firestore_export` to create a folder to store the exported database @@ -53,7 +54,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started ## Deploying -0. `$ firebase use prod` to switch to prod +0. After merging, you need to manually deploy to backend: +1. `git checkout main` +1. `git pull origin main` +1. `$ firebase use prod` to switch to prod 1. `$ firebase deploy --only functions` to push your changes live! (Future TODO: auto-deploy functions on Git push) diff --git a/functions/src/claim-manalink.ts b/functions/src/claim-manalink.ts index 3822bbf7..b534f0a3 100644 --- a/functions/src/claim-manalink.ts +++ b/functions/src/claim-manalink.ts @@ -28,6 +28,9 @@ export const claimmanalink = newEndpoint({}, async (req, auth) => { if (amount <= 0 || isNaN(amount) || !isFinite(amount)) throw new APIError(500, 'Invalid amount') + if (auth.uid === fromId) + throw new APIError(400, `You can't claim your own manalink`) + const fromDoc = firestore.doc(`users/${fromId}`) const fromSnap = await transaction.get(fromDoc) if (!fromSnap.exists) { diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 1fb6c3af..7cc05760 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -15,11 +15,11 @@ import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' -import { Group } from '../../common/group' +import { Group, GROUP_CHAT_SLUG } from '../../common/group' const firestore = admin.firestore() type user_to_reason_texts = { - [userId: string]: { reason: notification_reason_types; isSeeOnHref?: string } + [userId: string]: { reason: notification_reason_types } } export const createNotification = async ( @@ -29,12 +29,22 @@ export const createNotification = async ( sourceUser: User, idempotencyKey: string, sourceText: string, - sourceContract?: Contract, - relatedSourceType?: notification_source_types, - relatedUserId?: string, - sourceSlug?: string, - sourceTitle?: string + miscData?: { + contract?: Contract + relatedSourceType?: notification_source_types + relatedUserId?: string + slug?: string + title?: string + } ) => { + const { + contract: sourceContract, + relatedSourceType, + relatedUserId, + slug, + title, + } = miscData ?? {} + const shouldGetNotification = ( userId: string, userToReasonTexts: user_to_reason_texts @@ -70,9 +80,8 @@ export const createNotification = async ( sourceContractCreatorUsername: sourceContract?.creatorUsername, sourceContractTitle: sourceContract?.question, sourceContractSlug: sourceContract?.slug, - sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, - sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, - isSeenOnHref: userToReasonTexts[userId].isSeeOnHref, + sourceSlug: slug ? slug : sourceContract?.slug, + sourceTitle: title ? title : sourceContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) }) @@ -254,20 +263,6 @@ export const createNotification = async ( } } - const notifyUserReceivedReferralBonus = async ( - userToReasonTexts: user_to_reason_texts, - relatedUserId: string - ) => { - if (shouldGetNotification(relatedUserId, userToReasonTexts)) - userToReasonTexts[relatedUserId] = { - // If the referrer is the market creator, just tell them they joined to bet on their market - reason: - sourceContract?.creatorId === relatedUserId - ? 'user_joined_to_bet_on_your_market' - : 'you_referred_user', - } - } - const notifyContractCreatorOfUniqueBettorsBonus = async ( userToReasonTexts: user_to_reason_texts, userId: string @@ -277,17 +272,6 @@ export const createNotification = async ( } } - const notifyOtherGroupMembersOfComment = async ( - userToReasons: user_to_reason_texts, - userId: string - ) => { - if (shouldGetNotification(userId, userToReasons)) - userToReasons[userId] = { - reason: 'on_group_you_are_member_of', - isSeeOnHref: sourceSlug, - } - } - const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -296,10 +280,6 @@ export const createNotification = async ( } else if (sourceType === 'group' && relatedUserId) { if (sourceUpdateType === 'created') await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) - } else if (sourceType === 'user' && relatedUserId) { - await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId) - } else if (sourceType === 'comment' && !sourceContract && relatedUserId) { - await notifyOtherGroupMembersOfComment(userToReasonTexts, relatedUserId) } // The following functions need sourceContract to be defined. @@ -417,3 +397,84 @@ export const createBetFillNotification = async ( } 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: comment.text, + sourceSlug, + sourceTitle: `${group.name}`, + isSeenOnHref: sourceSlug, + } + await notificationRef.set(removeUndefinedProps(notification)) +} + +export const createReferralNotification = async ( + toUser: User, + referredUser: User, + idempotencyKey: string, + bonusAmount: string, + referredByContract?: Contract, + referredByGroup?: Group +) => { + const notificationRef = firestore + .collection(`/users/${toUser.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUser.id, + reason: referredByGroup + ? 'user_joined_from_your_group_invite' + : referredByContract?.creatorId === toUser.id + ? 'user_joined_to_bet_on_your_market' + : 'you_referred_user', + createdTime: Date.now(), + isSeen: false, + sourceId: referredUser.id, + sourceType: 'user', + sourceUpdateType: 'updated', + sourceContractId: referredByContract?.id, + sourceUserName: referredUser.name, + sourceUserUsername: referredUser.username, + sourceUserAvatarUrl: referredUser.avatarUrl, + sourceText: bonusAmount, + // Only pass the contract referral details if they weren't referred to a group + sourceContractCreatorUsername: !referredByGroup + ? referredByContract?.creatorUsername + : undefined, + sourceContractTitle: !referredByGroup + ? referredByContract?.question + : undefined, + sourceContractSlug: !referredByGroup ? referredByContract?.slug : undefined, + sourceSlug: referredByGroup + ? groupPath(referredByGroup.slug) + : referredByContract?.slug, + sourceTitle: referredByGroup + ? referredByGroup.name + : referredByContract?.question, + } + await notificationRef.set(removeUndefinedProps(notification)) +} + +const groupPath = (groupSlug: string) => `/group/${groupSlug}` diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 332c1872..1f413b6d 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,6 +1,8 @@ import * as admin from 'firebase-admin' import { z } from 'zod' import { + MANIFOLD_AVATAR_URL, + MANIFOLD_USERNAME, PrivateUser, STARTING_BALANCE, SUS_STARTING_BALANCE, @@ -157,11 +159,11 @@ const addUserToDefaultGroups = async (user: User) => { id: welcomeCommentDoc.id, groupId: group.id, userId: manifoldAccount, - text: `Welcome, ${user.name} (@${user.username})!`, + text: `Welcome, @${user.username} aka ${user.name}!`, createdTime: Date.now(), userName: 'Manifold Markets', - userUsername: 'ManifoldMarkets', - userAvatarUrl: 'https://manifold.markets/logo-bg-white.png', + userUsername: MANIFOLD_USERNAME, + userAvatarUrl: MANIFOLD_AVATAR_URL, }) } } diff --git a/functions/src/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html new file mode 100644 index 00000000..5f0c450e --- /dev/null +++ b/functions/src/email-templates/500-mana.html @@ -0,0 +1,29 @@ + diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 60534679..a29f982c 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -302,7 +302,7 @@ export const sendNewCommentEmail = async ( )}` } - const subject = `Comment from ${commentorName} on ${question}` + const subject = `Comment on ${question}` const from = `${commentorName} on Manifold ` if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { diff --git a/functions/src/index.ts b/functions/src/index.ts index 3055f8dc..df311886 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -22,6 +22,7 @@ export * from './on-update-user' export * from './on-create-comment-on-group' export * from './on-create-txn' export * from './on-delete-group' +export * from './score-contracts' // v2 export * from './health' diff --git a/functions/src/market-close-notifications.ts b/functions/src/market-close-notifications.ts index ee9952bf..f31674a1 100644 --- a/functions/src/market-close-notifications.ts +++ b/functions/src/market-close-notifications.ts @@ -64,7 +64,7 @@ async function sendMarketCloseEmails() { user, 'closed' + contract.id.slice(6, contract.id.length), contract.closeTime?.toString() ?? new Date().toString(), - contract + { contract } ) } } diff --git a/functions/src/on-create-answer.ts b/functions/src/on-create-answer.ts index 78fd1399..af4690b0 100644 --- a/functions/src/on-create-answer.ts +++ b/functions/src/on-create-answer.ts @@ -28,6 +28,6 @@ export const onCreateAnswer = functions.firestore answerCreator, eventId, answer.text, - contract + { contract } ) }) diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index fc2e0053..d33e71dd 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -64,10 +64,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( if (!previousUniqueBettorIds) { const contractBets = ( - await firestore - .collection(`contracts/${contractId}/bets`) - .where('userId', '!=', contract.creatorId) - .get() + await firestore.collection(`contracts/${contractId}/bets`).get() ).docs.map((doc) => doc.data() as Bet) if (contractBets.length === 0) { @@ -82,9 +79,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( ) } - const isNewUniqueBettor = - !previousUniqueBettorIds.includes(bettorId) && - bettorId !== contract.creatorId + const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId) const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId]) // Update contract unique bettors @@ -96,7 +91,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( uniqueBettorCount: newUniqueBettorIds.length, }) } - if (!isNewUniqueBettor) return + + // No need to give a bonus for the creator's bet + if (!isNewUniqueBettor || bettorId == contract.creatorId) return // Create combined txn for all new unique bettors const bonusTxnDetails = { @@ -134,12 +131,11 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( fromUser, eventId + '-bonus', result.txn.amount + '', - contract, - undefined, - // No need to set the user id, we'll use the contract creator id - undefined, - contract.slug, - contract.question + { + contract, + slug: contract.slug, + title: contract.question, + } ) } } diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index f7839b44..8d841ac0 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -68,7 +68,7 @@ export const onCreateCommentOnContract = functions ? 'answer' : undefined - const relatedUser = comment.replyToCommentId + const relatedUserId = comment.replyToCommentId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId @@ -79,9 +79,7 @@ export const onCreateCommentOnContract = functions commentCreator, eventId, comment.text, - contract, - relatedSourceType, - relatedUser + { contract, relatedSourceType, relatedUserId } ) const recipientUserIds = uniq([ diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts index 7217e602..0064480f 100644 --- a/functions/src/on-create-comment-on-group.ts +++ b/functions/src/on-create-comment-on-group.ts @@ -3,7 +3,7 @@ import { Comment } from '../../common/comment' import * as admin from 'firebase-admin' import { Group } from '../../common/group' import { User } from '../../common/user' -import { createNotification } from './create-notification' +import { createGroupCommentNotification } from './create-notification' const firestore = admin.firestore() export const onCreateCommentOnGroup = functions.firestore @@ -29,23 +29,17 @@ export const onCreateCommentOnGroup = functions.firestore const group = groupSnapshot.data() as Group await firestore.collection('groups').doc(groupId).update({ - mostRecentActivityTime: comment.createdTime, + mostRecentChatActivityTime: comment.createdTime, }) await Promise.all( group.memberIds.map(async (memberId) => { - return await createNotification( - comment.id, - 'comment', - 'created', + return await createGroupCommentNotification( creatorSnapshot.data() as User, - eventId, - comment.text, - undefined, - undefined, memberId, - `/group/${group.slug}`, - `${group.name}` + comment, + group, + eventId ) }) ) diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index 28682793..a43beda7 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -21,6 +21,6 @@ export const onCreateContract = functions.firestore contractCreator, eventId, richTextToString(contract.description as JSONContent), - contract + { contract } ) }) diff --git a/functions/src/on-create-group.ts b/functions/src/on-create-group.ts index 1d041c04..47618d7a 100644 --- a/functions/src/on-create-group.ts +++ b/functions/src/on-create-group.ts @@ -20,11 +20,11 @@ export const onCreateGroup = functions.firestore groupCreator, eventId, group.about, - undefined, - undefined, - memberId, - group.slug, - group.name + { + relatedUserId: memberId, + slug: group.slug, + title: group.name, + } ) } }) diff --git a/functions/src/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts index d55b2be4..ba17f3e7 100644 --- a/functions/src/on-create-liquidity-provision.ts +++ b/functions/src/on-create-liquidity-provision.ts @@ -26,6 +26,6 @@ export const onCreateLiquidityProvision = functions.firestore liquidityProvider, eventId, liquidity.amount.toString(), - contract + { contract } ) }) diff --git a/functions/src/on-delete-group.ts b/functions/src/on-delete-group.ts index ca833254..e5531d7b 100644 --- a/functions/src/on-delete-group.ts +++ b/functions/src/on-delete-group.ts @@ -3,6 +3,7 @@ import * as admin from 'firebase-admin' import { Group } from 'common/group' import { Contract } from 'common/contract' + const firestore = admin.firestore() export const onDeleteGroup = functions.firestore @@ -15,17 +16,21 @@ export const onDeleteGroup = functions.firestore .collection('contracts') .where('groupSlugs', 'array-contains', group.slug) .get() + console.log("contracts with group's slug:", contracts) for (const doc of contracts.docs) { const contract = doc.data() as Contract + const newGroupLinks = contract.groupLinks?.filter( + (link) => link.slug !== group.slug + ) + // remove the group from the contract await firestore .collection('contracts') .doc(contract.id) .update({ - groupSlugs: (contract.groupSlugs ?? []).filter( - (groupSlug) => groupSlug !== group.slug - ), + groupSlugs: contract.groupSlugs?.filter((s) => s !== group.slug), + groupLinks: newGroupLinks ?? [], }) } }) diff --git a/functions/src/on-follow-user.ts b/functions/src/on-follow-user.ts index ad85f4d3..9a6e6dce 100644 --- a/functions/src/on-follow-user.ts +++ b/functions/src/on-follow-user.ts @@ -30,9 +30,7 @@ export const onFollowUser = functions.firestore followingUser, eventId, '', - undefined, - undefined, - follow.userId + { relatedUserId: follow.userId } ) }) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 4674bd82..2042f726 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -36,7 +36,7 @@ export const onUpdateContract = functions.firestore contractUpdater, eventId, resolutionText, - contract + { contract } ) } else if ( previousValue.closeTime !== contract.closeTime || @@ -62,7 +62,7 @@ export const onUpdateContract = functions.firestore contractUpdater, eventId, sourceText, - contract + { contract } ) } }) diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index feaa6443..3ab2a249 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -12,7 +12,15 @@ export const onUpdateGroup = functions.firestore // ignore the update we just made if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) return - // TODO: create notification with isSeeOnHref set to the group's /group/questions url + + if (prevGroup.contractIds.length < group.contractIds.length) { + await firestore + .collection('groups') + .doc(group.id) + .update({ mostRecentContractAddedTime: Date.now() }) + //TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url + // but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two + } await firestore .collection('groups') diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts index 0ace3c53..f5558730 100644 --- a/functions/src/on-update-user.ts +++ b/functions/src/on-update-user.ts @@ -2,11 +2,12 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { REFERRAL_AMOUNT, User } from '../../common/user' import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' -import { createNotification } from './create-notification' +import { createReferralNotification } from './create-notification' import { ReferralTxn } from '../../common/txn' import { Contract } from '../../common/contract' import { LimitBet } from 'common/bet' import { QuerySnapshot } from 'firebase-admin/firestore' +import { Group } from 'common/group' const firestore = admin.firestore() export const onUpdateUser = functions.firestore @@ -54,6 +55,17 @@ async function handleUserUpdatedReferral(user: User, eventId: string) { } console.log(`referredByContract: ${referredByContract}`) + let referredByGroup: Group | undefined = undefined + if (user.referredByGroupId) { + const referredByGroupDoc = firestore.doc( + `groups/${user.referredByGroupId}` + ) + referredByGroup = await transaction + .get(referredByGroupDoc) + .then((snap) => snap.data() as Group) + } + console.log(`referredByGroup: ${referredByGroup}`) + const txns = ( await firestore .collection('txns') @@ -100,18 +112,13 @@ async function handleUserUpdatedReferral(user: User, eventId: string) { totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT, }) - await createNotification( - user.id, - 'user', - 'updated', + await createReferralNotification( + referredByUser, user, eventId, txn.amount.toString(), referredByContract, - 'user', - referredByUser.id, - referredByContract?.slug, - referredByContract?.question + referredByGroup ) }) } diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index b8c0ca0e..97ff9780 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -6,7 +6,7 @@ import { Query, Transaction, } from 'firebase-admin/firestore' -import { groupBy, mapValues, sumBy } from 'lodash' +import { groupBy, mapValues, sumBy, uniq } from 'lodash' import { APIError, newEndpoint, validate } from './api' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' @@ -153,10 +153,10 @@ export const placebet = newEndpoint({}, async (req, auth) => { log('Main transaction finished.') if (result.newBet.amount !== 0) { - const userIds = [ + const userIds = uniq([ auth.uid, ...(result.makers ?? []).map((maker) => maker.bet.userId), - ] + ]) await Promise.all(userIds.map((userId) => redeemShares(userId, contractId))) log('Share redemption transaction finished.') } diff --git a/functions/src/score-contracts.ts b/functions/src/score-contracts.ts new file mode 100644 index 00000000..57976ff2 --- /dev/null +++ b/functions/src/score-contracts.ts @@ -0,0 +1,54 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { Bet } from 'common/bet' +import { uniq } from 'lodash' +import { Contract } from 'common/contract' +import { log } from './utils' + +export const scoreContracts = functions.pubsub + .schedule('every 1 hours') + .onRun(async () => { + await scoreContractsInternal() + }) +const firestore = admin.firestore() + +async function scoreContractsInternal() { + const now = Date.now() + const lastHour = now - 60 * 60 * 1000 + const last3Days = now - 1000 * 60 * 60 * 24 * 3 + const activeContractsSnap = await firestore + .collection('contracts') + .where('lastUpdatedTime', '>', lastHour) + .get() + const activeContracts = activeContractsSnap.docs.map( + (doc) => doc.data() as Contract + ) + // We have to downgrade previously active contracts to allow the new ones to bubble up + const previouslyActiveContractsSnap = await firestore + .collection('contracts') + .where('popularityScore', '>', 0) + .get() + const activeContractIds = activeContracts.map((c) => c.id) + const previouslyActiveContracts = previouslyActiveContractsSnap.docs + .map((doc) => doc.data() as Contract) + .filter((c) => !activeContractIds.includes(c.id)) + + const contracts = activeContracts.concat(previouslyActiveContracts) + log(`Found ${contracts.length} contracts to score`) + + for (const contract of contracts) { + const bets = await firestore + .collection(`contracts/${contract.id}/bets`) + .where('createdTime', '>', last3Days) + .get() + const bettors = bets.docs + .map((doc) => doc.data() as Bet) + .map((bet) => bet.userId) + const score = uniq(bettors).length + if (contract.popularityScore !== score) + await firestore + .collection('contracts') + .doc(contract.id) + .update({ popularityScore: score }) + } +} diff --git a/functions/src/scripts/convert-categories.ts b/functions/src/scripts/convert-categories.ts index 8fe90807..3436bcbc 100644 --- a/functions/src/scripts/convert-categories.ts +++ b/functions/src/scripts/convert-categories.ts @@ -1,14 +1,9 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -initAdmin() - import { getValues, isProd } from '../utils' -import { - CATEGORIES_GROUP_SLUG_POSTFIX, - DEFAULT_CATEGORIES, -} from 'common/categories' -import { Group } from 'common/group' +import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories' +import { Group, GroupLink } from 'common/group' import { uniq } from 'lodash' import { Contract } from 'common/contract' import { User } from 'common/user' @@ -18,28 +13,12 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID, } from 'common/antes' +initAdmin() + const adminFirestore = admin.firestore() -async function convertCategoriesToGroups() { - const groups = await getValues(adminFirestore.collection('groups')) - const contracts = await getValues( - adminFirestore.collection('contracts') - ) - for (const group of groups) { - const groupContracts = contracts.filter((contract) => - group.contractIds.includes(contract.id) - ) - for (const contract of groupContracts) { - await adminFirestore - .collection('contracts') - .doc(contract.id) - .update({ - groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]), - }) - } - } - - for (const category of Object.values(DEFAULT_CATEGORIES)) { +const convertCategoriesToGroupsInternal = async (categories: string[]) => { + for (const category of categories) { const markets = await getValues( adminFirestore .collection('contracts') @@ -77,7 +56,7 @@ async function convertCategoriesToGroups() { createdTime: Date.now(), anyoneCanJoin: true, memberIds: [manifoldAccount], - about: 'Official group for all things related to ' + category, + about: 'Default group for all things related to ' + category, mostRecentActivityTime: Date.now(), contractIds: markets.map((market) => market.id), chatDisabled: true, @@ -93,16 +72,35 @@ async function convertCategoriesToGroups() { }) for (const market of markets) { + if (market.groupLinks?.map((l) => l.groupId).includes(newGroup.id)) + continue // already in that group + + const newGroupLinks = [ + ...(market.groupLinks ?? []), + { + groupId: newGroup.id, + createdTime: Date.now(), + slug: newGroup.slug, + name: newGroup.name, + } as GroupLink, + ] await adminFirestore .collection('contracts') .doc(market.id) .update({ - groupSlugs: uniq([...(market?.groupSlugs ?? []), newGroup.slug]), + groupSlugs: uniq([...(market.groupSlugs ?? []), newGroup.slug]), + groupLinks: newGroupLinks, }) } } } +async function convertCategoriesToGroups() { + // const defaultCategories = Object.values(DEFAULT_CATEGORIES) + const moreCategories = ['world', 'culture'] + await convertCategoriesToGroupsInternal(moreCategories) +} + if (require.main === module) { convertCategoriesToGroups() .then(() => process.exit()) diff --git a/functions/src/scripts/link-contracts-to-groups.ts b/functions/src/scripts/link-contracts-to-groups.ts new file mode 100644 index 00000000..e3296160 --- /dev/null +++ b/functions/src/scripts/link-contracts-to-groups.ts @@ -0,0 +1,53 @@ +import { getValues } from 'functions/src/utils' +import { Group } from 'common/group' +import { Contract } from 'common/contract' +import { initAdmin } from 'functions/src/scripts/script-init' +import * as admin from 'firebase-admin' +import { filterDefined } from 'common/util/array' +import { uniq } from 'lodash' + +initAdmin() + +const adminFirestore = admin.firestore() + +const addGroupIdToContracts = async () => { + const groups = await getValues(adminFirestore.collection('groups')) + + for (const group of groups) { + const groupContracts = await getValues( + adminFirestore + .collection('contracts') + .where('groupSlugs', 'array-contains', group.slug) + ) + + for (const contract of groupContracts) { + const oldGroupLinks = contract.groupLinks?.filter( + (l) => l.slug != group.slug + ) + const newGroupLinks = filterDefined([ + ...(oldGroupLinks ?? []), + group.id + ? { + slug: group.slug, + name: group.name, + groupId: group.id, + createdTime: Date.now(), + } + : undefined, + ]) + await adminFirestore + .collection('contracts') + .doc(contract.id) + .update({ + groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]), + groupLinks: newGroupLinks, + }) + } + } +} + +if (require.main === module) { + addGroupIdToContracts() + .then(() => process.exit()) + .catch(console.log) +} diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 3407760b..40ea0f4a 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -1,4 +1,4 @@ -import { sumBy } from 'lodash' +import { sumBy, uniq } from 'lodash' import * as admin from 'firebase-admin' import { z } from 'zod' @@ -7,11 +7,12 @@ import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { User } from '../../common/user' import { getCpmmSellBetInfo } from '../../common/sell-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' -import { getValues } from './utils' +import { getValues, log } from './utils' import { Bet } from '../../common/bet' import { floatingLesserEqual } from '../../common/util/math' import { getUnfilledBetsQuery, updateMakers } from './place-bet' import { FieldValue } from 'firebase-admin/firestore' +import { redeemShares } from './redeem-shares' const bodySchema = z.object({ contractId: z.string(), @@ -23,7 +24,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const { contractId, shares, outcome } = validate(bodySchema, req.body) // Run as transaction to prevent race conditions. - return await firestore.runTransaction(async (transaction) => { + const result = await firestore.runTransaction(async (transaction) => { const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) @@ -97,8 +98,14 @@ export const sellshares = newEndpoint({}, async (req, auth) => { }) ) - return { status: 'success' } + return { newBet, makers } }) + + const userIds = uniq(result.makers.map((maker) => maker.bet.userId)) + await Promise.all(userIds.map((userId) => redeemShares(userId, contractId))) + log('Share redemption transaction finished.') + + return { status: 'success' } }) const firestore = admin.firestore() diff --git a/web/.prettierignore b/web/.prettierignore index b79c5513..6cc1e5c7 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -1,3 +1,4 @@ # Ignore Next artifacts .next/ -out/ \ No newline at end of file +out/ +public/**/*.json \ No newline at end of file diff --git a/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx new file mode 100644 index 00000000..7a839a7a --- /dev/null +++ b/web/components/NotificationSettings.tsx @@ -0,0 +1,210 @@ +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' + +export function NotificationSettings() { + const user = useUser() + const [notificationSettings, setNotificationSettings] = + useState('all') + const [emailNotificationSettings, setEmailNotificationSettings] = + useState('all') + const [privateUser, setPrivateUser] = useState(null) + + 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 + highlight: boolean + }) { + const { label, highlight } = 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: + + + + + +
+
+
+
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/amount-input.tsx b/web/components/amount-input.tsx index a31957cb..426a9371 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -41,7 +41,7 @@ export function AmountInput(props: { {label} { + let deviceToken = localStorage.getItem('device-token') + if (!deviceToken) { + deviceToken = randomString() + localStorage.setItem('device-token', deviceToken) + } + return deviceToken +} + +export const AuthContext = createContext(null) + +export function AuthProvider({ children }: any) { + const [authUser, setAuthUser] = useStateCheckEquality(undefined) + + useEffect(() => { + const cachedUser = localStorage.getItem(CACHED_USER_KEY) + setAuthUser(cachedUser && JSON.parse(cachedUser)) + }, [setAuthUser]) + + useEffect(() => { + return onIdTokenChanged(auth, async (fbUser) => { + if (fbUser) { + setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken) + let user = await getUser(fbUser.uid) + if (!user) { + const deviceToken = ensureDeviceToken() + user = (await createUser({ deviceToken })) as User + } + setAuthUser(user) + // Persist to local storage, to reduce login blink next time. + // Note: Cap on localStorage size is ~5mb + localStorage.setItem(CACHED_USER_KEY, JSON.stringify(user)) + setCachedReferralInfoForUser(user) + } else { + // User logged out; reset to null + deleteAuthCookies() + setAuthUser(null) + localStorage.removeItem(CACHED_USER_KEY) + } + }) + }, [setAuthUser]) + + const authUserId = authUser?.id + const authUsername = authUser?.username + useEffect(() => { + if (authUserId && authUsername) { + identifyUser(authUserId) + setUserProperty('username', authUsername) + return listenForUser(authUserId, setAuthUser) + } + }, [authUserId, authUsername, setAuthUser]) + + return ( + {children} + ) +} diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index 53257deb..0436d61c 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -31,6 +31,7 @@ export function Avatar(props: { !noLink && 'cursor-pointer', className )} + style={{ maxWidth: `${s * 0.25}rem` }} src={avatarUrl} onClick={onClick} alt={username} diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 8343d696..c638fcde 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx' import React, { useEffect, useState } from 'react' -import { partition, sum, sumBy } from 'lodash' +import { clamp, partition, sum, sumBy } from 'lodash' import { useUser } from 'web/hooks/use-user' import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' @@ -13,33 +13,35 @@ import { formatPercent, formatWithCommas, } from 'common/util/format' -import { getBinaryCpmmBetInfo } from 'common/new-bet' +import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet' import { User } from 'web/lib/firebase/users' import { Bet, LimitBet } from 'common/bet' import { APIError, placeBet } from 'web/lib/firebase/api' import { sellShares } from 'web/lib/firebase/api' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' -import { BinaryOutcomeLabel } from './outcome-label' +import { + BinaryOutcomeLabel, + HigherLabel, + LowerLabel, + NoLabel, + YesLabel, +} from './outcome-label' import { getProbability } from 'common/calculate' import { useFocus } from 'web/hooks/use-focus' import { useUserContractBets } from 'web/hooks/use-user-bets' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' -import { - getFormattedMappedValue, - getPseudoProbability, -} from 'common/pseudo-numeric' +import { getFormattedMappedValue } from 'common/pseudo-numeric' import { SellRow } from './sell-row' import { useSaveBinaryShares } from './use-save-binary-shares' import { SignUpPrompt } from './sign-up-prompt' import { isIOS } from 'web/lib/util/device' -import { ProbabilityInput } from './probability-input' +import { ProbabilityOrNumericInput } from './probability-input' import { track } from 'web/lib/service/analytics' -import { removeUndefinedProps } from 'common/util/object' import { useUnfilledBets } from 'web/hooks/use-bets' import { LimitBets } from './limit-bets' -import { BucketInput } from './bucket-input' import { PillButton } from './buttons/pill-button' +import { YesNoSelector } from './yes-no-selector' export function BetPanel(props: { contract: CPMMBinaryContract | PseudoNumericContract @@ -49,14 +51,10 @@ export function BetPanel(props: { const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) const unfilledBets = useUnfilledBets(contract.id) ?? [] - const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id) const { sharesOutcome } = useSaveBinaryShares(contract, userBets) const [isLimitOrder, setIsLimitOrder] = useState(false) - const showLimitOrders = - (isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0 - return (