diff --git a/common/categories.ts b/common/categories.ts index 232aa526..672f3200 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', @@ -37,3 +38,8 @@ export const EXCLUDED_CATEGORIES: category[] = [ ] 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..b1242ab9 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -48,6 +48,7 @@ export type Contract = { groupSlugs?: string[] 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 f8aaf4cc..5bd12095 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -22,6 +22,7 @@ export type EnvConfig = { // Currency controls fixedAnte?: number startingBalance?: number + referralBonus?: number } type FirebaseConfig = { 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/pseudo-numeric.ts b/common/pseudo-numeric.ts index c99e670f..73f9fd01 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 @@ -38,7 +38,7 @@ export const getPseudoProbability = ( isLogScale = false ) => { 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 6eed3bdb..0dac5a19 100644 --- a/common/user.ts +++ b/common/user.ts @@ -38,13 +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 diff --git a/common/util/format.ts b/common/util/format.ts index 7dc1a341..4f123535 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -33,20 +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 < 1) return num.toPrecision(sigfigs) + if (absNum < 1) return showPrecision(num, sigfigs) - if (absNum < 100) return num.toPrecision(2) - if (absNum < 1000) return num.toPrecision(3) - if (absNum < 10000) return num.toPrecision(4) + 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 i = Math.floor(Math.log10(absNum) / 3) - const numStr = (num / Math.pow(10, 3 * i)).toPrecision(sigfigs) - return `${numStr}${suffix[i]}` + const numStr = showPrecision(num / Math.pow(10, 3 * i), sigfigs) + return `${numStr}${suffix[i] ?? ''}` } export function toCamelCase(words: string) { diff --git a/firestore.rules b/firestore.rules index 84c3e990..96378d8b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -22,11 +22,11 @@ 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', 'lastPingTime']); + .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']) + .hasOnly(['referredByUserId', 'referredByContractId', 'referredByGroupId']) // only one referral allowed per user && !("referredByUserId" in resource.data) // user can't refer themselves @@ -76,7 +76,7 @@ service cloud.firestore { allow update: if request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['tags', 'lowercaseTags', 'groupSlugs']); 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/src/create-notification.ts b/functions/src/create-notification.ts index 4c42b00e..bf2dd28a 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -253,20 +253,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 @@ -284,8 +270,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) } // The following functions need sourceContract to be defined. @@ -411,6 +395,7 @@ export const createGroupCommentNotification = async ( group: Group, idempotencyKey: string ) => { + if (toUserId === fromUser.id) return const notificationRef = firestore .collection(`/users/${toUserId}/notifications`) .doc(idempotencyKey) @@ -434,3 +419,52 @@ export const createGroupCommentNotification = async ( } 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/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/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/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/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/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 7c4ada58..dadf6516 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -53,14 +53,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 ( - {showLimitOrders && ( + {unfilledBets.length > 0 && ( )} @@ -116,9 +112,6 @@ export function SimpleBetPanel(props: { const [isLimitOrder, setIsLimitOrder] = useState(false) const unfilledBets = useUnfilledBets(contract.id) ?? [] - const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id) - const showLimitOrders = - (isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0 return ( @@ -149,7 +142,7 @@ export function SimpleBetPanel(props: { - {showLimitOrders && ( + {unfilledBets.length > 0 && ( )} diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index db6b0d05..2114ec2b 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -50,7 +50,7 @@ import { LimitOrderTable } from './limit-bets' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' -const CONTRACTS_PER_PAGE = 20 +const CONTRACTS_PER_PAGE = 50 export function BetsList(props: { user: User diff --git a/web/components/button.tsx b/web/components/button.tsx index 8cdeacdd..d279d9a0 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -39,7 +39,7 @@ export function Button(props: { color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', - color === 'gray' && 'bg-gray-200 text-gray-700 hover:bg-gray-300', + color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200', className )} disabled={disabled} diff --git a/web/components/buttons/pill-button.tsx b/web/components/buttons/pill-button.tsx index 796036d1..5b4962b7 100644 --- a/web/components/buttons/pill-button.tsx +++ b/web/components/buttons/pill-button.tsx @@ -13,7 +13,7 @@ export function PillButton(props: { return ( + + + + ) : ( + <> + + + + {isAdmin && 'Admin: '} + + + + + ) } -function EditContract(props: { - text: string - onSave: (newText: string) => void - buttonText: string +function EditQuestion(props: { + contract: Contract + editing: boolean + setEditing: (editing: boolean) => void }) { - const [text, setText] = useState(props.text) - const [editing, setEditing] = useState(false) - const onSave = (newText: string) => { + const { contract, editing, setEditing } = props + const [text, setText] = useState(contract.question) + + function questionChanged(oldQ: string, newQ: string) { + return `

${editTimestamp()}${oldQ} → ${newQ}

` + } + + function joinContent(oldContent: ContentType, newContent: string) { + const editor = new Editor({ content: oldContent, extensions: exhibitExts }) + editor.chain().focus('end').insertContent(newContent).run() + return editor.getJSON() + } + + const onSave = async (newText: string) => { setEditing(false) - setText(props.text) // Reset to original text - props.onSave(newText) + await updateContract(contract.id, { + question: newText, + description: joinContent( + contract.description, + questionChanged(contract.question, newText) + ), + }) } return editing ? (