From 58ef43a8ecca396ebf9e4a57354eee678eb780f0 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Tue, 13 Sep 2022 21:11:53 -0500 Subject: [PATCH 01/37] intro panel: use gradient image --- web/components/market-intro-panel.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web/components/market-intro-panel.tsx b/web/components/market-intro-panel.tsx index ef4d28a2..6b326fc6 100644 --- a/web/components/market-intro-panel.tsx +++ b/web/components/market-intro-panel.tsx @@ -9,10 +9,11 @@ export function MarketIntroPanel() {
Play-money predictions
Manifold Markets gradient logo
@@ -22,5 +23,5 @@ export function MarketIntroPanel() { - ) + } From be851b83829f1f2bfc267bee3d9496cc2298fef3 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Tue, 13 Sep 2022 21:23:36 -0500 Subject: [PATCH 02/37] fix typo --- web/components/market-intro-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/market-intro-panel.tsx b/web/components/market-intro-panel.tsx index 6b326fc6..11bdf1df 100644 --- a/web/components/market-intro-panel.tsx +++ b/web/components/market-intro-panel.tsx @@ -23,5 +23,5 @@ export function MarketIntroPanel() { - + ) } From e7d8cfe7e0a8fd68585746746b0eb53b42d6f6b1 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Wed, 14 Sep 2022 00:26:47 -0500 Subject: [PATCH 03/37] House liquidity (#876) * add house liquidity for unique bettors * hide notifications from house liquidity * up bonus liquidity to M$20 --- common/add-liquidity.ts | 5 +- common/antes.ts | 2 + functions/src/add-liquidity.ts | 47 ++++++++++++++++++- functions/src/on-create-bet.ts | 9 +++- .../src/on-create-liquidity-provision.ts | 7 ++- 5 files changed, 62 insertions(+), 8 deletions(-) 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..ba7c95e8 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -16,6 +16,8 @@ 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 + export function getCpmmInitialLiquidity( providerId: string, contract: CPMMBinaryContract, 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/on-create-bet.ts b/functions/src/on-create-bet.ts index f2c6b51a..6b5f7eac 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -24,6 +24,8 @@ 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' const firestore = admin.firestore() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() @@ -149,18 +151,23 @@ 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, }) } + if (contract.mechanism === 'cpmm-1' && isNewUniqueBettor) { + await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT) + } + // No need to give a bonus for the creator's bet if (!isNewUniqueBettor || bettor.id == contract.creatorId) return 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 From 273b815e544bf377669324cb3bbfdd0e7ecda0b9 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Wed, 14 Sep 2022 00:51:43 -0500 Subject: [PATCH 04/37] hide house liquidity on feed --- web/components/feed/feed-liquidity.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx index e2a80624..ba0cd490 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,6 +28,13 @@ 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 ? ( From 1ebb505752bd4e8ccb5cf2de69d19b2a162eee8b Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Wed, 14 Sep 2022 01:13:53 -0700 Subject: [PATCH 05/37] Fix liquidity feed display to look right (#877) --- web/components/feed/feed-liquidity.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx index ba0cd490..8f8faf9b 100644 --- a/web/components/feed/feed-liquidity.tsx +++ b/web/components/feed/feed-liquidity.tsx @@ -36,7 +36,7 @@ export function FeedLiquidity(props: { return <> return ( - + {isSelf ? ( ) : bettor ? ( From 7144e57c93a6254c656d8b5574d685517ec07539 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Wed, 14 Sep 2022 01:33:59 -0700 Subject: [PATCH 06/37] Denormalize user display fields onto bets (#853) * Denormalize user display fields onto bets * Make bet denormalization script fast enough to run it on prod * Make `placeBet`/`sellShares` immediately post denormalized info --- common/antes.ts | 16 ++++--- common/bet.ts | 6 +++ common/new-bet.ts | 7 ++- common/sell-bet.ts | 5 +- functions/src/change-user-info.ts | 12 +++++ functions/src/on-create-bet.ts | 6 +++ functions/src/place-bet.ts | 9 +++- .../src/scripts/denormalize-avatar-urls.ts | 48 +++++++------------ .../src/scripts/denormalize-bet-user-data.ts | 38 +++++++++++++++ .../scripts/denormalize-comment-bet-data.ts | 30 ++++++------ .../denormalize-comment-contract-data.ts | 24 ++++------ functions/src/scripts/denormalize.ts | 45 +++++++++++------ functions/src/sell-shares.ts | 3 ++ .../contract/contract-leaderboard.tsx | 5 +- web/components/feed/feed-bets.tsx | 34 +++++-------- web/components/limit-bets.tsx | 8 ++-- 16 files changed, 180 insertions(+), 116 deletions(-) create mode 100644 functions/src/scripts/denormalize-bet-user-data.ts diff --git a/common/antes.ts b/common/antes.ts index ba7c95e8..51aac20f 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -15,9 +15,13 @@ 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, contract: CPMMBinaryContract, @@ -53,7 +57,7 @@ export function getAnteBets( const { createdTime } = contract - const yesBet: Bet = { + const yesBet: NormalizedBet = { id: yesAnteId, userId: creator.id, contractId: contract.id, @@ -67,7 +71,7 @@ export function getAnteBets( fees: noFees, } - const noBet: Bet = { + const noBet: NormalizedBet = { id: noAnteId, userId: creator.id, contractId: contract.id, @@ -95,7 +99,7 @@ export function getFreeAnswerAnte( const { createdTime } = contract - const anteBet: Bet = { + const anteBet: NormalizedBet = { id: anteBetId, userId: anteBettorId, contractId: contract.id, @@ -125,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, @@ -175,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/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/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/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/on-create-bet.ts b/functions/src/on-create-bet.ts index 6b5f7eac..f54d6475 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -61,6 +61,12 @@ export const onCreateBet = functions 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) 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/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/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/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/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/limit-bets.tsx b/web/components/limit-bets.tsx index 466b7a9b..606bc7e0 100644 --- a/web/components/limit-bets.tsx +++ b/web/components/limit-bets.tsx @@ -4,7 +4,7 @@ import { getFormattedMappedValue } from 'common/pseudo-numeric' import { formatMoney, formatPercent } from 'common/util/format' import { sortBy } from 'lodash' import { useState } from 'react' -import { useUser, useUserById } from 'web/hooks/use-user' +import { useUser } from 'web/hooks/use-user' import { cancelBet } from 'web/lib/firebase/api' import { Avatar } from './avatar' import { Button } from './button' @@ -109,16 +109,14 @@ function LimitBet(props: { setIsCancelling(true) } - const user = useUserById(bet.userId) - return ( {!isYou && ( )} From a2d61a1daa2b3276c8e1d60682a0161db8d54593 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Wed, 14 Sep 2022 03:52:31 -0500 Subject: [PATCH 07/37] Twitch integration (#815) * twitch account linking; profile page twitch panel; twitch landing page * fix import * twitch logo * save twitch credentials cloud function * use user id instead of bot id, add manifold api endpoint * properly add function to index * Added support for new redirect Twitch auth. * Added clean error handling in case of Twitch link fail. * remove simulator * Removed legacy non-redirect Twitch auth code. Added "add bot to channel" button in user profile and relevant data to user type. * Removed unnecessary imports. * Fixed line endings. * Allow users to modify private user twitchInfo firestore object * Local dev on savetwitchcredentials function Co-authored-by: Phil Co-authored-by: Marshall Polaris --- common/user.ts | 5 + firestore.rules | 4 +- functions/src/index.ts | 3 + functions/src/save-twitch-credentials.ts | 22 ++++ functions/src/serve.ts | 2 + web/components/profile/twitch-panel.tsx | 133 +++++++++++++++++++++++ web/lib/api/api-key.ts | 9 ++ web/lib/twitch/link-twitch-account.ts | 41 +++++++ web/pages/api/v0/twitch/save.ts | 23 ++++ web/pages/profile.tsx | 17 ++- web/pages/twitch.tsx | 120 ++++++++++++++++++++ web/public/twitch-logo.png | Bin 0 -> 23022 bytes 12 files changed, 367 insertions(+), 12 deletions(-) create mode 100644 functions/src/save-twitch-credentials.ts create mode 100644 web/components/profile/twitch-panel.tsx create mode 100644 web/lib/api/api-key.ts create mode 100644 web/lib/twitch/link-twitch-account.ts create mode 100644 web/pages/api/v0/twitch/save.ts create mode 100644 web/pages/twitch.tsx create mode 100644 web/public/twitch-logo.png diff --git a/common/user.ts b/common/user.ts index f8b4f8d8..5d427744 100644 --- a/common/user.ts +++ b/common/user.ts @@ -68,6 +68,11 @@ export type PrivateUser = { /** @deprecated - use notificationSubscriptionTypes */ notificationPreferences?: notification_subscribe_types notificationSubscriptionTypes: notification_subscription_types + twitchInfo?: { + twitchName: string + controlToken: string + botEnabled?: boolean + } } export type notification_destination_types = 'email' | 'browser' diff --git a/firestore.rules b/firestore.rules index d24d4097..82392787 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','notificationSubscriptionTypes' ]); + .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails', 'notificationSubscriptionTypes', 'twitchInfo']); } match /private-users/{userId}/views/{viewId} { @@ -161,7 +161,7 @@ service cloud.firestore { && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['isSeen', 'viewTime']); } - + match /{somePath=**}/groupMembers/{memberId} { allow read; } 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/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/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/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 =
+ + 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 ( + + +
+ +
+ + + + + + + +
+

+
+ + Bet + {' '} + on your favorite streams +
+

+ +
+ Get more out of Twitch with play-money betting markets.{' '} + {!twitchUser && + 'Click the button below to link your Twitch account.'} +
+
+
+ + + + {twitchUser ? ( +
+
+
+ Twitch account linked +
+
+ {twitchUser} +
+
+
+ ) : isLoading ? ( + + ) : ( + + )} + + + +
+ ) +} diff --git a/web/public/twitch-logo.png b/web/public/twitch-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7f575e7a1012aeafae3b44de35d61acfbe40a554 GIT binary patch literal 23022 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 aq_+(@Pw`-Heg3;WSy@5-ZH=6H@c#gIvA;tA literal 0 HcmV?d00001 From d6b0a1edc03b558ac0365b78056c00d21ae75966 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 14 Sep 2022 07:27:20 -0600 Subject: [PATCH 08/37] Betting streak reset to 7am UTC and store streak data on notif --- common/economy.ts | 2 +- common/notification.ts | 7 ++++++- functions/src/create-notification.ts | 6 ++++++ functions/src/on-create-bet.ts | 24 +++++++++++++++--------- web/pages/notifications.tsx | 13 +++++++------ 5 files changed, 35 insertions(+), 17 deletions(-) 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/notification.ts b/common/notification.ts index 42dbbf35..47c55cc6 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -18,7 +18,7 @@ export type Notification = { sourceUserUsername?: string sourceUserAvatarUrl?: string sourceText?: string - data?: string + data?: { [key: string]: any } sourceContractTitle?: string sourceContractCreatorUsername?: string @@ -157,3 +157,8 @@ export const getDestinationsForUser = async ( urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, } } + +export type BettingStreakData = { + streak: number + bonusAmount: number +} diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index e2959dda..34a8f218 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -1,5 +1,6 @@ import * as admin from 'firebase-admin' import { + BettingStreakData, getDestinationsForUser, Notification, notification_reason_types, @@ -686,6 +687,7 @@ export const createBettingStreakBonusNotification = async ( bet: Bet, contract: Contract, amount: number, + streak: number, idempotencyKey: string ) => { const privateUser = await getPrivateUser(user.id) @@ -719,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)) } diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index f54d6475..5fe3fd62 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -26,6 +26,7 @@ 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() @@ -80,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 @@ -128,6 +133,7 @@ const updateBettingStreak = async ( bet, contract, bonusAmount, + newBettingStreak, eventId ) } @@ -170,13 +176,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( }) } - if (contract.mechanism === 'cpmm-1' && isNewUniqueBettor) { - await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT) - } - // 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, @@ -259,6 +265,6 @@ const notifyFills = async ( ) } -const getTodaysBettingStreakResetTime = () => { +const currentDateBettingStreakResetTime = () => { return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) } diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index fcac8601..008f5df1 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -298,7 +298,7 @@ function IncomeNotificationGroupItem(props: { ...notificationsForSourceTitle[0], sourceText: sum.toString(), sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername, - data: JSON.stringify(uniqueUsers), + data: { uniqueUsers }, } newNotifications.push(newNotification) } @@ -415,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]) @@ -443,10 +443,11 @@ function IncomeNotificationItem(props: { 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 From edbae16c8ee8eb567aaaed379060c3f7357f7e99 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 14 Sep 2022 08:56:05 -0600 Subject: [PATCH 09/37] Betting streak reset indicator --- .../profile/betting-streak-modal.tsx | 43 ++++++++++++++++++- web/components/user-page.tsx | 13 +++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index a137833c..4d1d63be 100644 --- a/web/components/profile/betting-streak-modal.tsx +++ b/web/components/profile/betting-streak-modal.tsx @@ -3,19 +3,44 @@ 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 ( - 🔥 + + 🔥 + + {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? @@ -37,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/user-page.tsx b/web/components/user-page.tsx index 81aed562..5485267c 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -28,7 +28,10 @@ import { ReferralsButton } from 'web/components/referrals-button' import { formatMoney } from 'common/util/format' import { ShareIconButton } from 'web/components/share-icon-button' import { ENV_CONFIG } from 'common/envs/constants' -import { BettingStreakModal } from 'web/components/profile/betting-streak-modal' +import { + BettingStreakModal, + hasCompletedStreakToday, +} from 'web/components/profile/betting-streak-modal' import { REFERRAL_AMOUNT } from 'common/economy' import { LoansModal } from './profile/loans-modal' import { UserLikesButton } from 'web/components/profile/user-likes-button' @@ -83,6 +86,7 @@ export function UserPage(props: { user: User }) { {showLoansModal && ( @@ -139,7 +143,12 @@ export function UserPage(props: { user: User }) { profit setShowBettingStreakModal(true)} > 🔥 {user.currentBettingStreak ?? 0} From 7ba2eab65ea927ba183a0ba6948a10649390cab2 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 14 Sep 2022 10:26:08 -0600 Subject: [PATCH 10/37] Rename user notification preferences --- common/notification.ts | 4 +- common/user.ts | 80 +++++++------------ firestore.rules | 2 +- functions/src/create-user.ts | 2 +- functions/src/emails.ts | 10 +-- .../create-new-notification-preferences.ts | 2 +- functions/src/scripts/create-private-users.ts | 2 +- .../update-notification-preferences.ts | 29 +++++++ web/components/notification-settings.tsx | 6 +- 9 files changed, 73 insertions(+), 64 deletions(-) create mode 100644 functions/src/scripts/update-notification-preferences.ts diff --git a/common/notification.ts b/common/notification.ts index 47c55cc6..affa33cb 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -138,7 +138,7 @@ export const getDestinationsForUser = async ( privateUser: PrivateUser, reason: notification_reason_types | keyof notification_subscription_types ) => { - const notificationSettings = privateUser.notificationSubscriptionTypes + const notificationSettings = privateUser.notificationPreferences let destinations let subscriptionType: keyof notification_subscription_types | undefined if (Object.keys(notificationSettings).includes(reason)) { @@ -151,9 +151,11 @@ export const getDestinationsForUser = async ( ? notificationSettings[subscriptionType] : [] } + // const unsubscribeEndpoint = getFunctionUrl('unsubscribe') return { sendToEmail: destinations.includes('email'), sendToBrowser: destinations.includes('browser'), + // unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, } } diff --git a/common/user.ts b/common/user.ts index 5d427744..7bd89906 100644 --- a/common/user.ts +++ b/common/user.ts @@ -65,9 +65,7 @@ export type PrivateUser = { initialDeviceToken?: string initialIpAddress?: string apiKey?: string - /** @deprecated - use notificationSubscriptionTypes */ - notificationPreferences?: notification_subscribe_types - notificationSubscriptionTypes: notification_subscription_types + notificationPreferences: notification_subscription_types twitchInfo?: { twitchName: string controlToken: string @@ -142,9 +140,6 @@ export const getDefaultNotificationSettings = ( privateUser?: PrivateUser, noEmails?: boolean ) => { - const prevPref = privateUser?.notificationPreferences ?? 'all' - const wantsLess = prevPref === 'less' - const wantsAll = prevPref === 'all' const { unsubscribedFromCommentEmails, unsubscribedFromAnswerEmails, @@ -161,111 +156,96 @@ export const getDefaultNotificationSettings = ( return { // Watched Markets all_comments_on_watched_markets: constructPref( - wantsAll, + true, !unsubscribedFromCommentEmails ), all_answers_on_watched_markets: constructPref( - wantsAll, + true, !unsubscribedFromAnswerEmails ), // Comments - tips_on_your_comments: constructPref( - wantsAll || wantsLess, - !unsubscribedFromCommentEmails - ), - comments_by_followed_users_on_watched_markets: constructPref( - wantsAll, - false - ), + 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( - wantsAll || wantsLess, + true, !unsubscribedFromCommentEmails ), all_replies_to_my_answers_on_watched_markets: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromCommentEmails ), all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref( - wantsAll, + true, !unsubscribedFromCommentEmails ), // Answers answers_by_followed_users_on_watched_markets: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromAnswerEmails ), answers_by_market_creator_on_watched_markets: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromAnswerEmails ), all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref( - wantsAll, + true, !unsubscribedFromAnswerEmails ), // On users' markets your_contract_closed: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromResolutionEmails ), // High priority all_comments_on_my_markets: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromCommentEmails ), all_answers_on_my_markets: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromAnswerEmails ), - subsidized_your_market: constructPref(wantsAll || wantsLess, true), + subsidized_your_market: constructPref(true, true), // Market updates resolutions_on_watched_markets: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromResolutionEmails ), - market_updates_on_watched_markets: constructPref( - wantsAll || wantsLess, - false - ), + market_updates_on_watched_markets: constructPref(true, false), market_updates_on_watched_markets_with_shares_in: constructPref( - wantsAll || wantsLess, + true, false ), resolutions_on_watched_markets_with_shares_in: constructPref( - wantsAll || wantsLess, + true, !unsubscribedFromResolutionEmails ), //Balance Changes - loan_income: constructPref(wantsAll || wantsLess, false), - betting_streaks: constructPref(wantsAll || wantsLess, false), - referral_bonuses: constructPref(wantsAll || wantsLess, true), - unique_bettors_on_your_contract: constructPref( - wantsAll || wantsLess, - false - ), + 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( - wantsAll || wantsLess, + true, !unsubscribedFromCommentEmails ), - tips_on_your_markets: constructPref(wantsAll || wantsLess, true), - limit_order_fills: constructPref(wantsAll || wantsLess, false), + tips_on_your_markets: constructPref(true, true), + limit_order_fills: constructPref(true, false), // General - tagged_user: constructPref(wantsAll || wantsLess, true), - on_new_follow: constructPref(wantsAll || wantsLess, true), - contract_from_followed_user: constructPref(wantsAll || wantsLess, true), + 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( - wantsAll || wantsLess, - false - ), + probability_updates_on_watched_markets: constructPref(true, false), thank_you_for_purchases: constructPref( false, !unsubscribedFromGenericEmails diff --git a/firestore.rules b/firestore.rules index 82392787..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', 'notificationSubscriptionTypes', 'twitchInfo']); + .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'unsubscribedFromWeeklyTrendingEmails', 'notificationPreferences', 'twitchInfo']); } match /private-users/{userId}/views/{viewId} { diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 71272222..ab5f014a 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -83,7 +83,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { email, initialIpAddress: req.ip, initialDeviceToken: deviceToken, - notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid), + notificationPreferences: getDefaultNotificationSettings(auth.uid), } await firestore.collection('private-users').doc(auth.uid).create(privateUser) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index e9ef9630..bb9f7195 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -214,7 +214,7 @@ export const sendOneWeekBonusEmail = async ( if ( !privateUser || !privateUser.email || - !privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email') + !privateUser.notificationPreferences.onboarding_flow.includes('email') ) return @@ -247,7 +247,7 @@ export const sendCreatorGuideEmail = async ( if ( !privateUser || !privateUser.email || - !privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email') + !privateUser.notificationPreferences.onboarding_flow.includes('email') ) return @@ -279,7 +279,7 @@ export const sendThankYouEmail = async ( if ( !privateUser || !privateUser.email || - !privateUser.notificationSubscriptionTypes.thank_you_for_purchases.includes( + !privateUser.notificationPreferences.thank_you_for_purchases.includes( 'email' ) ) @@ -460,9 +460,7 @@ export const sendInterestingMarketsEmail = async ( if ( !privateUser || !privateUser.email || - !privateUser.notificationSubscriptionTypes.trending_markets.includes( - 'email' - ) + !privateUser.notificationPreferences.trending_markets.includes('email') ) return diff --git a/functions/src/scripts/create-new-notification-preferences.ts b/functions/src/scripts/create-new-notification-preferences.ts index a6bd1a0b..2796f2f7 100644 --- a/functions/src/scripts/create-new-notification-preferences.ts +++ b/functions/src/scripts/create-new-notification-preferences.ts @@ -17,7 +17,7 @@ async function main() { .collection('private-users') .doc(privateUser.id) .update({ - notificationSubscriptionTypes: getDefaultNotificationSettings( + notificationPreferences: getDefaultNotificationSettings( privateUser.id, privateUser, disableEmails diff --git a/functions/src/scripts/create-private-users.ts b/functions/src/scripts/create-private-users.ts index f9b8c3a1..21e117cf 100644 --- a/functions/src/scripts/create-private-users.ts +++ b/functions/src/scripts/create-private-users.ts @@ -21,7 +21,7 @@ async function main() { id: user.id, email, username, - notificationSubscriptionTypes: getDefaultNotificationSettings(user.id), + notificationPreferences: getDefaultNotificationSettings(user.id), } if (user.totalDeposits === undefined) { diff --git a/functions/src/scripts/update-notification-preferences.ts b/functions/src/scripts/update-notification-preferences.ts new file mode 100644 index 00000000..0e2dc243 --- /dev/null +++ b/functions/src/scripts/update-notification-preferences.ts @@ -0,0 +1,29 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getPrivateUser } from 'functions/src/utils' +import { filterDefined } from 'common/lib/util/array' +import { FieldValue } from 'firebase-admin/firestore' +initAdmin() + +const firestore = admin.firestore() + +async function main() { + // const privateUsers = await getAllPrivateUsers() + const privateUsers = filterDefined([ + await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), + ]) + 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/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 83ebf894..d18896bd 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -183,8 +183,8 @@ export function NotificationSettings(props: { toast .promise( updatePrivateUser(privateUser.id, { - notificationSubscriptionTypes: { - ...privateUser.notificationSubscriptionTypes, + notificationPreferences: { + ...privateUser.notificationPreferences, [subscriptionTypeKey]: destinations.includes(setting) ? destinations.filter((d) => d !== setting) : uniq([...destinations, setting]), @@ -240,7 +240,7 @@ export function NotificationSettings(props: { const getUsersSavedPreference = ( key: keyof notification_subscription_types ) => { - return privateUser.notificationSubscriptionTypes[key] ?? [] + return privateUser.notificationPreferences[key] ?? [] } const Section = memo(function Section(props: { From 050bd14e465fc23e5958e736bd4248db92f602be Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 14 Sep 2022 10:29:48 -0600 Subject: [PATCH 11/37] Update script --- functions/src/scripts/update-notification-preferences.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/functions/src/scripts/update-notification-preferences.ts b/functions/src/scripts/update-notification-preferences.ts index 0e2dc243..efea57b8 100644 --- a/functions/src/scripts/update-notification-preferences.ts +++ b/functions/src/scripts/update-notification-preferences.ts @@ -1,18 +1,14 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -import { getPrivateUser } from 'functions/src/utils' -import { filterDefined } from 'common/lib/util/array' +import { getAllPrivateUsers } from 'functions/src/utils' import { FieldValue } from 'firebase-admin/firestore' initAdmin() const firestore = admin.firestore() async function main() { - // const privateUsers = await getAllPrivateUsers() - const privateUsers = filterDefined([ - await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), - ]) + const privateUsers = await getAllPrivateUsers() await Promise.all( privateUsers.map((privateUser) => { if (!privateUser.id) return Promise.resolve() From 7aaacf4d505f62b13983033af1d0b8f48d390a93 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Wed, 14 Sep 2022 13:19:12 -0700 Subject: [PATCH 12/37] Center tweets --- web/components/editor/tweet-embed.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/editor/tweet-embed.tsx b/web/components/editor/tweet-embed.tsx index 91b2fa65..fb7d7810 100644 --- a/web/components/editor/tweet-embed.tsx +++ b/web/components/editor/tweet-embed.tsx @@ -12,7 +12,7 @@ export default function WrappedTwitterTweetEmbed(props: { const tweetId = props.node.attrs.tweetId.slice(1) return ( - + ) From 68b0539fc1ca5399550b3305742c43fb97c8d229 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Wed, 14 Sep 2022 15:06:11 -0700 Subject: [PATCH 13/37] Enable search exclusion and exact searches like `-musk` to remove Elon results or `"eth"` for Ethereum results --- web/components/contract-search.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 5bd69057..7f64b26b 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -164,6 +164,7 @@ export function ContractSearch(props: { numericFilters, page: requestedPage, hitsPerPage: 20, + advancedSyntax: true, }) // if there's a more recent request, forget about this one if (id === requestId.current) { From 3efd968058176905018cdd0af8beab9894a187fc Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 14 Sep 2022 17:17:32 -0600 Subject: [PATCH 14/37] Allow one-click unsubscribe, slight refactor --- common/notification.ts | 219 ++++++++---- common/user-notification-preferences.ts | 243 +++++++++++++ common/user.ts | 174 +--------- functions/src/create-notification.ts | 24 +- functions/src/create-user.ts | 9 +- functions/src/email-templates/500-mana.html | 321 ------------------ .../src/email-templates/creating-market.html | 2 +- .../email-templates/interesting-markets.html | 2 +- .../market-answer-comment.html | 2 +- .../src/email-templates/market-answer.html | 2 +- .../src/email-templates/market-close.html | 2 +- .../src/email-templates/market-comment.html | 2 +- .../market-resolved-no-bets.html | 2 +- .../src/email-templates/market-resolved.html | 2 +- .../new-market-from-followed-user.html | 2 +- .../email-templates/new-unique-bettor.html | 2 +- .../email-templates/new-unique-bettors.html | 2 +- functions/src/email-templates/one-week.html | 2 +- functions/src/email-templates/thank-you.html | 2 +- functions/src/email-templates/welcome.html | 2 +- functions/src/emails.ts | 61 ++-- .../create-new-notification-preferences.ts | 4 +- functions/src/scripts/create-private-users.ts | 5 +- functions/src/unsubscribe.ts | 252 +++++++++++--- web/components/notification-settings.tsx | 132 ++++--- 25 files changed, 723 insertions(+), 749 deletions(-) create mode 100644 common/user-notification-preferences.ts delete mode 100644 functions/src/email-templates/500-mana.html diff --git a/common/notification.ts b/common/notification.ts index affa33cb..c34f5b9c 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -1,5 +1,4 @@ -import { notification_subscription_types, PrivateUser } from './user' -import { DOMAIN } from './envs/constants' +import { notification_preference } from './user-notification-preferences' export type Notification = { id: string @@ -29,6 +28,7 @@ export type Notification = { isSeenOnHref?: string } + export type notification_source_types = | 'contract' | 'comment' @@ -54,7 +54,7 @@ export type notification_source_update_types = | 'deleted' | 'closed' -/* Optional - if possible use a keyof notification_subscription_types */ +/* Optional - if possible use a notification_preference */ export type notification_reason_types = | 'tagged_user' | 'on_new_follow' @@ -92,75 +92,152 @@ export type notification_reason_types = | 'your_contract_closed' | 'subsidized_your_market' -// Adding a new key:value here is optional, you can just use a key of notification_subscription_types -// You might want to add a key:value here if there will be multiple notification reasons that map to the same -// subscription type, i.e. 'comment_on_contract_you_follow' and 'comment_on_contract_with_users_answer' both map to -// 'all_comments_on_watched_markets' subscription type -// TODO: perhaps better would be to map notification_subscription_types to arrays of notification_reason_types -export const notificationReasonToSubscriptionType: Partial< - Record -> = { - you_referred_user: 'referral_bonuses', - user_joined_to_bet_on_your_market: 'referral_bonuses', - tip_received: 'tips_on_your_comments', - bet_fill: 'limit_order_fills', - user_joined_from_your_group_invite: 'referral_bonuses', - challenge_accepted: 'limit_order_fills', - betting_streak_incremented: 'betting_streaks', - liked_and_tipped_your_contract: 'tips_on_your_markets', - comment_on_your_contract: 'all_comments_on_my_markets', - answer_on_your_contract: 'all_answers_on_my_markets', - comment_on_contract_you_follow: 'all_comments_on_watched_markets', - answer_on_contract_you_follow: 'all_answers_on_watched_markets', - update_on_contract_you_follow: 'market_updates_on_watched_markets', - resolution_on_contract_you_follow: 'resolutions_on_watched_markets', - comment_on_contract_with_users_shares_in: - 'all_comments_on_contracts_with_shares_in_on_watched_markets', - answer_on_contract_with_users_shares_in: - 'all_answers_on_contracts_with_shares_in_on_watched_markets', - update_on_contract_with_users_shares_in: - 'market_updates_on_watched_markets_with_shares_in', - resolution_on_contract_with_users_shares_in: - 'resolutions_on_watched_markets_with_shares_in', - comment_on_contract_with_users_answer: 'all_comments_on_watched_markets', - update_on_contract_with_users_answer: 'market_updates_on_watched_markets', - resolution_on_contract_with_users_answer: 'resolutions_on_watched_markets', - answer_on_contract_with_users_answer: 'all_answers_on_watched_markets', - comment_on_contract_with_users_comment: 'all_comments_on_watched_markets', - answer_on_contract_with_users_comment: 'all_answers_on_watched_markets', - update_on_contract_with_users_comment: 'market_updates_on_watched_markets', - resolution_on_contract_with_users_comment: 'resolutions_on_watched_markets', - reply_to_users_answer: 'all_replies_to_my_answers_on_watched_markets', - reply_to_users_comment: 'all_replies_to_my_comments_on_watched_markets', -} - -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 } + +type notification_descriptions = { + [key in notification_preference]: { + simple: string + detailed: string + } +} +export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { + all_answers_on_my_markets: { + simple: 'Answers on your markets', + detailed: 'Answers on your own markets', + }, + all_comments_on_my_markets: { + simple: 'Comments on your markets', + detailed: 'Comments on your own markets', + }, + answers_by_followed_users_on_watched_markets: { + simple: 'Only answers by users you follow', + detailed: "Only answers by users you follow on markets you're watching", + }, + answers_by_market_creator_on_watched_markets: { + simple: 'Only answers by market creator', + detailed: "Only answers by market creator on markets you're watching", + }, + betting_streaks: { + simple: 'For predictions made over consecutive days', + detailed: 'Bonuses for predictions made over consecutive days', + }, + comments_by_followed_users_on_watched_markets: { + simple: 'Only comments by users you follow', + detailed: + 'Only comments by users that you follow on markets that you watch', + }, + contract_from_followed_user: { + simple: 'New markets from users you follow', + detailed: 'New markets from users you follow', + }, + limit_order_fills: { + simple: 'Limit order fills', + detailed: 'When your limit order is filled by another user', + }, + loan_income: { + simple: 'Automatic loans from your predictions in unresolved markets', + detailed: + 'Automatic loans from your predictions that are locked in unresolved markets', + }, + market_updates_on_watched_markets: { + simple: 'All creator updates', + detailed: 'All market updates made by the creator', + }, + market_updates_on_watched_markets_with_shares_in: { + simple: "Only creator updates on markets that you're invested in", + detailed: + "Only updates made by the creator on markets that you're invested in", + }, + on_new_follow: { + simple: 'A user followed you', + detailed: 'A user followed you', + }, + onboarding_flow: { + simple: 'Emails to help you get started using Manifold', + detailed: 'Emails to help you learn how to use Manifold', + }, + probability_updates_on_watched_markets: { + simple: 'Large changes in probability on markets that you watch', + detailed: 'Large changes in probability on markets that you watch', + }, + profit_loss_updates: { + simple: 'Weekly profit and loss updates', + detailed: 'Weekly profit and loss updates', + }, + referral_bonuses: { + simple: 'For referring new users', + detailed: 'Bonuses you receive from referring a new user', + }, + resolutions_on_watched_markets: { + simple: 'All market resolutions', + detailed: "All resolutions on markets that you're watching", + }, + resolutions_on_watched_markets_with_shares_in: { + simple: "Only market resolutions that you're invested in", + detailed: + "Only resolutions of markets you're watching and that you're invested in", + }, + subsidized_your_market: { + simple: 'Your market was subsidized', + detailed: 'When someone subsidizes your market', + }, + tagged_user: { + simple: 'A user tagged you', + detailed: 'When another use tags you', + }, + thank_you_for_purchases: { + simple: 'Thank you notes for your purchases', + detailed: 'Thank you notes for your purchases', + }, + tipped_comments_on_watched_markets: { + simple: 'Only highly tipped comments on markets that you watch', + detailed: 'Only highly tipped comments on markets that you watch', + }, + tips_on_your_comments: { + simple: 'Tips on your comments', + detailed: 'Tips on your comments', + }, + tips_on_your_markets: { + simple: 'Tips/Likes on your markets', + detailed: 'Tips/Likes on your markets', + }, + trending_markets: { + simple: 'Weekly interesting markets', + detailed: 'Weekly interesting markets', + }, + unique_bettors_on_your_contract: { + simple: 'For unique predictors on your markets', + detailed: 'Bonuses for unique predictors on your markets', + }, + your_contract_closed: { + simple: 'Your market has closed and you need to resolve it', + detailed: 'Your market has closed and you need to resolve it', + }, + all_comments_on_watched_markets: { + simple: 'All new comments', + detailed: 'All new comments on markets you follow', + }, + all_comments_on_contracts_with_shares_in_on_watched_markets: { + simple: `Only on markets you're invested in`, + detailed: `Comments on markets that you're watching and you're invested in`, + }, + all_replies_to_my_comments_on_watched_markets: { + simple: 'Only replies to your comments', + detailed: "Only replies to your comments on markets you're watching", + }, + all_replies_to_my_answers_on_watched_markets: { + simple: 'Only replies to your answers', + detailed: "Only replies to your answers on markets you're watching", + }, + all_answers_on_watched_markets: { + simple: 'All new answers', + detailed: "All new answers on markets you're watching", + }, + all_answers_on_contracts_with_shares_in_on_watched_markets: { + simple: `Only on markets you're invested in`, + detailed: `Answers on markets that you're watching and that you're invested in`, + }, +} diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts new file mode 100644 index 00000000..e2402ea9 --- /dev/null +++ b/common/user-notification-preferences.ts @@ -0,0 +1,243 @@ +import { filterDefined } from './util/array' +import { notification_reason_types } from './notification' +import { getFunctionUrl } from './api' +import { DOMAIN } from './envs/constants' +import { PrivateUser } from './user' + +export type notification_destination_types = 'email' | 'browser' +export type notification_preference = keyof notification_preferences +export type notification_preferences = { + // Watched Markets + all_comments_on_watched_markets: notification_destination_types[] + all_answers_on_watched_markets: notification_destination_types[] + + // Comments + tipped_comments_on_watched_markets: notification_destination_types[] + comments_by_followed_users_on_watched_markets: notification_destination_types[] + all_replies_to_my_comments_on_watched_markets: notification_destination_types[] + all_replies_to_my_answers_on_watched_markets: notification_destination_types[] + all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[] + + // Answers + answers_by_followed_users_on_watched_markets: notification_destination_types[] + answers_by_market_creator_on_watched_markets: notification_destination_types[] + all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[] + + // On users' markets + your_contract_closed: notification_destination_types[] + all_comments_on_my_markets: notification_destination_types[] + all_answers_on_my_markets: notification_destination_types[] + subsidized_your_market: notification_destination_types[] + + // Market updates + resolutions_on_watched_markets: notification_destination_types[] + resolutions_on_watched_markets_with_shares_in: notification_destination_types[] + market_updates_on_watched_markets: notification_destination_types[] + market_updates_on_watched_markets_with_shares_in: notification_destination_types[] + probability_updates_on_watched_markets: notification_destination_types[] + + // Balance Changes + loan_income: notification_destination_types[] + betting_streaks: notification_destination_types[] + referral_bonuses: notification_destination_types[] + unique_bettors_on_your_contract: notification_destination_types[] + tips_on_your_comments: notification_destination_types[] + tips_on_your_markets: notification_destination_types[] + limit_order_fills: notification_destination_types[] + + // General + tagged_user: notification_destination_types[] + on_new_follow: notification_destination_types[] + contract_from_followed_user: notification_destination_types[] + trending_markets: notification_destination_types[] + profit_loss_updates: notification_destination_types[] + onboarding_flow: notification_destination_types[] + thank_you_for_purchases: notification_destination_types[] +} + +export const getDefaultNotificationPreferences = ( + userId: string, + privateUser?: PrivateUser, + noEmails?: boolean +) => { + const { + 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_preferences +} + +// Adding a new key:value here is optional, you can just use a key of notification_subscription_types +// You might want to add a key:value here if there will be multiple notification reasons that map to the same +// subscription type, i.e. 'comment_on_contract_you_follow' and 'comment_on_contract_with_users_answer' both map to +// 'all_comments_on_watched_markets' subscription type +// TODO: perhaps better would be to map notification_subscription_types to arrays of notification_reason_types +const notificationReasonToSubscriptionType: Partial< + Record +> = { + you_referred_user: 'referral_bonuses', + user_joined_to_bet_on_your_market: 'referral_bonuses', + tip_received: 'tips_on_your_comments', + bet_fill: 'limit_order_fills', + user_joined_from_your_group_invite: 'referral_bonuses', + challenge_accepted: 'limit_order_fills', + betting_streak_incremented: 'betting_streaks', + liked_and_tipped_your_contract: 'tips_on_your_markets', + comment_on_your_contract: 'all_comments_on_my_markets', + answer_on_your_contract: 'all_answers_on_my_markets', + comment_on_contract_you_follow: 'all_comments_on_watched_markets', + answer_on_contract_you_follow: 'all_answers_on_watched_markets', + update_on_contract_you_follow: 'market_updates_on_watched_markets', + resolution_on_contract_you_follow: 'resolutions_on_watched_markets', + comment_on_contract_with_users_shares_in: + 'all_comments_on_contracts_with_shares_in_on_watched_markets', + answer_on_contract_with_users_shares_in: + 'all_answers_on_contracts_with_shares_in_on_watched_markets', + update_on_contract_with_users_shares_in: + 'market_updates_on_watched_markets_with_shares_in', + resolution_on_contract_with_users_shares_in: + 'resolutions_on_watched_markets_with_shares_in', + comment_on_contract_with_users_answer: 'all_comments_on_watched_markets', + update_on_contract_with_users_answer: 'market_updates_on_watched_markets', + resolution_on_contract_with_users_answer: 'resolutions_on_watched_markets', + answer_on_contract_with_users_answer: 'all_answers_on_watched_markets', + comment_on_contract_with_users_comment: 'all_comments_on_watched_markets', + answer_on_contract_with_users_comment: 'all_answers_on_watched_markets', + update_on_contract_with_users_comment: 'market_updates_on_watched_markets', + resolution_on_contract_with_users_comment: 'resolutions_on_watched_markets', + reply_to_users_answer: 'all_replies_to_my_answers_on_watched_markets', + reply_to_users_comment: 'all_replies_to_my_comments_on_watched_markets', +} + +export const getNotificationDestinationsForUser = ( + privateUser: PrivateUser, + reason: notification_reason_types | notification_preference +) => { + const notificationSettings = privateUser.notificationPreferences + let destinations + let subscriptionType: notification_preference | undefined + if (Object.keys(notificationSettings).includes(reason)) { + subscriptionType = reason as notification_preference + destinations = notificationSettings[subscriptionType] + } else { + const key = reason as notification_reason_types + subscriptionType = notificationReasonToSubscriptionType[key] + destinations = subscriptionType + ? notificationSettings[subscriptionType] + : [] + } + const unsubscribeEndpoint = getFunctionUrl('unsubscribe') + return { + sendToEmail: destinations.includes('email'), + sendToBrowser: destinations.includes('browser'), + unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, + urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + } +} diff --git a/common/user.ts b/common/user.ts index 7bd89906..16a2b437 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,4 +1,4 @@ -import { filterDefined } from './util/array' +import { notification_preferences } from './user-notification-preferences' export type User = { id: string @@ -65,7 +65,7 @@ export type PrivateUser = { initialDeviceToken?: string initialIpAddress?: string apiKey?: string - notificationPreferences: notification_subscription_types + notificationPreferences: notification_preferences twitchInfo?: { twitchName: string controlToken: string @@ -73,57 +73,6 @@ export type PrivateUser = { } } -export type notification_destination_types = 'email' | 'browser' -export type notification_subscription_types = { - // Watched Markets - all_comments_on_watched_markets: notification_destination_types[] - all_answers_on_watched_markets: notification_destination_types[] - - // Comments - tipped_comments_on_watched_markets: notification_destination_types[] - comments_by_followed_users_on_watched_markets: notification_destination_types[] - all_replies_to_my_comments_on_watched_markets: notification_destination_types[] - all_replies_to_my_answers_on_watched_markets: notification_destination_types[] - all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[] - - // Answers - answers_by_followed_users_on_watched_markets: notification_destination_types[] - answers_by_market_creator_on_watched_markets: notification_destination_types[] - all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[] - - // On users' markets - your_contract_closed: notification_destination_types[] - all_comments_on_my_markets: notification_destination_types[] - all_answers_on_my_markets: notification_destination_types[] - subsidized_your_market: notification_destination_types[] - - // Market updates - resolutions_on_watched_markets: notification_destination_types[] - resolutions_on_watched_markets_with_shares_in: notification_destination_types[] - market_updates_on_watched_markets: notification_destination_types[] - market_updates_on_watched_markets_with_shares_in: notification_destination_types[] - probability_updates_on_watched_markets: notification_destination_types[] - - // Balance Changes - loan_income: notification_destination_types[] - betting_streaks: notification_destination_types[] - referral_bonuses: notification_destination_types[] - unique_bettors_on_your_contract: notification_destination_types[] - tips_on_your_comments: notification_destination_types[] - tips_on_your_markets: notification_destination_types[] - limit_order_fills: notification_destination_types[] - - // General - tagged_user: notification_destination_types[] - on_new_follow: notification_destination_types[] - contract_from_followed_user: notification_destination_types[] - trending_markets: notification_destination_types[] - profit_loss_updates: notification_destination_types[] - onboarding_flow: notification_destination_types[] - thank_you_for_purchases: notification_destination_types[] -} -export type notification_subscribe_types = 'all' | 'less' | 'none' - export type PortfolioMetrics = { investmentValue: number balance: number @@ -134,122 +83,3 @@ 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/functions/src/create-notification.ts b/functions/src/create-notification.ts index 34a8f218..ba9fa5c4 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -1,7 +1,6 @@ import * as admin from 'firebase-admin' import { BettingStreakData, - getDestinationsForUser, Notification, notification_reason_types, } from '../../common/notification' @@ -27,6 +26,7 @@ import { sendNewUniqueBettorsEmail, } from './emails' import { filterDefined } from '../../common/util/array' +import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' const firestore = admin.firestore() type recipients_to_reason_texts = { @@ -66,7 +66,7 @@ export const createNotification = async ( const { reason } = userToReasonTexts[userId] const privateUser = await getPrivateUser(userId) if (!privateUser) continue - const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( privateUser, reason ) @@ -236,7 +236,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( return const privateUser = await getPrivateUser(userId) if (!privateUser) return - const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( privateUser, reason ) @@ -468,7 +468,7 @@ export const createTipNotification = async ( ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser } = getNotificationDestinationsForUser( privateUser, 'tip_received' ) @@ -513,7 +513,7 @@ export const createBetFillNotification = async ( ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser } = getNotificationDestinationsForUser( privateUser, 'bet_fill' ) @@ -558,7 +558,7 @@ export const createReferralNotification = async ( ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser } = getNotificationDestinationsForUser( privateUser, 'you_referred_user' ) @@ -612,7 +612,7 @@ export const createLoanIncomeNotification = async ( ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser } = getNotificationDestinationsForUser( privateUser, 'loan_income' ) @@ -650,7 +650,7 @@ export const createChallengeAcceptedNotification = async ( ) => { const privateUser = await getPrivateUser(challengeCreator.id) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser } = getNotificationDestinationsForUser( privateUser, 'challenge_accepted' ) @@ -692,7 +692,7 @@ export const createBettingStreakBonusNotification = async ( ) => { const privateUser = await getPrivateUser(user.id) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser } = getNotificationDestinationsForUser( privateUser, 'betting_streak_incremented' ) @@ -739,7 +739,7 @@ export const createLikeNotification = async ( ) => { const privateUser = await getPrivateUser(toUser.id) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser } = getNotificationDestinationsForUser( privateUser, 'liked_and_tipped_your_contract' ) @@ -786,7 +786,7 @@ export const createUniqueBettorBonusNotification = async ( ) => { const privateUser = await getPrivateUser(contractCreatorId) if (!privateUser) return - const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'unique_bettors_on_your_contract' ) @@ -876,7 +876,7 @@ export const createNewContractNotification = async ( ) => { const privateUser = await getPrivateUser(userId) if (!privateUser) return - const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( privateUser, reason ) diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index ab5f014a..ab70b4e6 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,11 +1,7 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { - getDefaultNotificationSettings, - PrivateUser, - User, -} from '../../common/user' +import { PrivateUser, User } from '../../common/user' import { getUser, getUserByUsername, getValues } from './utils' import { randomString } from '../../common/util/random' import { @@ -22,6 +18,7 @@ import { track } from './analytics' import { APIError, newEndpoint, validate } from './api' import { Group } from '../../common/group' import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy' +import { getDefaultNotificationPreferences } from '../../common/user-notification-preferences' const bodySchema = z.object({ deviceToken: z.string().optional(), @@ -83,7 +80,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { email, initialIpAddress: req.ip, initialDeviceToken: deviceToken, - notificationPreferences: getDefaultNotificationSettings(auth.uid), + notificationPreferences: getDefaultNotificationPreferences(auth.uid), } await firestore.collection('private-users').doc(auth.uid).create(privateUser) diff --git a/functions/src/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html deleted file mode 100644 index c8f6a171..00000000 --- a/functions/src/email-templates/500-mana.html +++ /dev/null @@ -1,321 +0,0 @@ - - - - - Manifold Markets 7th Day Anniversary Gift! - - - - - - - - - - - - - - - -
- -
- - - - - - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - -
-
-
-

- Hi {{name}},

-
-
-
-

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

-
-
-

-
- - - - -
- - - - -
- - Claim M$500 - -
-
-
-
-

Did - you know, besides making correct predictions, there are - plenty of other ways to earn mana?

- -

 

-

Cheers, -

-

David - from Manifold

-

 

-
-
-
-

- -

 

-

Cheers,

-

David from Manifold

-

 

-
-
-
- -
-
- -
- - - -
- -
- - - - diff --git a/functions/src/email-templates/interesting-markets.html b/functions/src/email-templates/interesting-markets.html index 7c3e653d..0cee6269 100644 --- a/functions/src/email-templates/interesting-markets.html +++ b/functions/src/email-templates/interesting-markets.html @@ -443,7 +443,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.

diff --git a/functions/src/email-templates/market-answer-comment.html b/functions/src/email-templates/market-answer-comment.html index a19aa7c3..4b98730f 100644 --- a/functions/src/email-templates/market-answer-comment.html +++ b/functions/src/email-templates/market-answer-comment.html @@ -529,7 +529,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.
- - - -
-
- - - - - -
- -
- - - - - - -
- - - - - - - - - -
-
-

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

-
-
-
-
-
-
-
- -
- - - - - - \ No newline at end of file diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html index c73f7458..bf163f69 100644 --- a/functions/src/email-templates/creating-market.html +++ b/functions/src/email-templates/creating-market.html @@ -494,7 +494,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.

diff --git a/functions/src/email-templates/market-answer.html b/functions/src/email-templates/market-answer.html index b2d7f727..e3d42b9d 100644 --- a/functions/src/email-templates/market-answer.html +++ b/functions/src/email-templates/market-answer.html @@ -369,7 +369,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.
diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index ee7976b0..4abd225e 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -487,7 +487,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification. diff --git a/functions/src/email-templates/market-comment.html b/functions/src/email-templates/market-comment.html index 23e20dac..ce0669f1 100644 --- a/functions/src/email-templates/market-comment.html +++ b/functions/src/email-templates/market-comment.html @@ -369,7 +369,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification. diff --git a/functions/src/email-templates/market-resolved-no-bets.html b/functions/src/email-templates/market-resolved-no-bets.html index ff5f541f..5d886adf 100644 --- a/functions/src/email-templates/market-resolved-no-bets.html +++ b/functions/src/email-templates/market-resolved-no-bets.html @@ -470,7 +470,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification. diff --git a/functions/src/email-templates/market-resolved.html b/functions/src/email-templates/market-resolved.html index de29a0f1..767202b6 100644 --- a/functions/src/email-templates/market-resolved.html +++ b/functions/src/email-templates/market-resolved.html @@ -502,7 +502,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification. diff --git a/functions/src/email-templates/new-market-from-followed-user.html b/functions/src/email-templates/new-market-from-followed-user.html index 877d554f..49633fb2 100644 --- a/functions/src/email-templates/new-market-from-followed-user.html +++ b/functions/src/email-templates/new-market-from-followed-user.html @@ -318,7 +318,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.

diff --git a/functions/src/email-templates/new-unique-bettor.html b/functions/src/email-templates/new-unique-bettor.html index 30da8b99..51026121 100644 --- a/functions/src/email-templates/new-unique-bettor.html +++ b/functions/src/email-templates/new-unique-bettor.html @@ -376,7 +376,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification. diff --git a/functions/src/email-templates/new-unique-bettors.html b/functions/src/email-templates/new-unique-bettors.html index eb4c04e2..09c44d03 100644 --- a/functions/src/email-templates/new-unique-bettors.html +++ b/functions/src/email-templates/new-unique-bettors.html @@ -480,7 +480,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification. diff --git a/functions/src/email-templates/one-week.html b/functions/src/email-templates/one-week.html index b8e233d5..e7d14a7e 100644 --- a/functions/src/email-templates/one-week.html +++ b/functions/src/email-templates/one-week.html @@ -283,7 +283,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.

diff --git a/functions/src/email-templates/thank-you.html b/functions/src/email-templates/thank-you.html index 7ac72d0a..beef11ee 100644 --- a/functions/src/email-templates/thank-you.html +++ b/functions/src/email-templates/thank-you.html @@ -218,7 +218,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.

diff --git a/functions/src/email-templates/welcome.html b/functions/src/email-templates/welcome.html index dccec695..d6caaa0c 100644 --- a/functions/src/email-templates/welcome.html +++ b/functions/src/email-templates/welcome.html @@ -290,7 +290,7 @@ click here to manage your notifications. + " target="_blank">click here to unsubscribe from this type of notification.

diff --git a/functions/src/emails.ts b/functions/src/emails.ts index bb9f7195..98309ebe 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -2,11 +2,7 @@ import { DOMAIN } from '../../common/envs/constants' import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' import { Contract } from '../../common/contract' -import { - notification_subscription_types, - PrivateUser, - User, -} from '../../common/user' +import { PrivateUser, User } from '../../common/user' import { formatLargeNumber, formatMoney, @@ -18,11 +14,12 @@ import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail, sendTextEmail } from './send-email' import { getUser } from './utils' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' -import { - notification_reason_types, - getDestinationsForUser, -} from '../../common/notification' +import { notification_reason_types } from '../../common/notification' import { Dictionary } from 'lodash' +import { + getNotificationDestinationsForUser, + notification_preference, +} from '../../common/user-notification-preferences' export const sendMarketResolutionEmail = async ( reason: notification_reason_types, @@ -36,8 +33,10 @@ export const sendMarketResolutionEmail = async ( resolutionProbability?: number, resolutions?: { [outcome: string]: number } ) => { - const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = - await getDestinationsForUser(privateUser, reason) + const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + reason + ) if (!privateUser || !privateUser.email || !sendToEmail) return const user = await getUser(privateUser.id) @@ -154,7 +153,7 @@ export const sendWelcomeEmail = async ( const firstName = name.split(' ')[0] const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as keyof notification_subscription_types + 'onboarding_flow' as notification_preference }` return await sendTemplateEmail( @@ -222,7 +221,7 @@ export const sendOneWeekBonusEmail = async ( const firstName = name.split(' ')[0] const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as keyof notification_subscription_types + 'onboarding_flow' as notification_preference }` return await sendTemplateEmail( privateUser.email, @@ -255,7 +254,7 @@ export const sendCreatorGuideEmail = async ( const firstName = name.split(' ')[0] const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as keyof notification_subscription_types + 'onboarding_flow' as notification_preference }` return await sendTemplateEmail( privateUser.email, @@ -289,7 +288,7 @@ export const sendThankYouEmail = async ( const firstName = name.split(' ')[0] const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'thank_you_for_purchases' as keyof notification_subscription_types + 'thank_you_for_purchases' as notification_preference }` return await sendTemplateEmail( @@ -312,8 +311,10 @@ export const sendMarketCloseEmail = async ( privateUser: PrivateUser, contract: Contract ) => { - const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = - await getDestinationsForUser(privateUser, reason) + const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + reason + ) if (!privateUser.email || !sendToEmail) return @@ -350,8 +351,10 @@ export const sendNewCommentEmail = async ( answerText?: string, answerId?: string ) => { - const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = - await getDestinationsForUser(privateUser, reason) + const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + reason + ) if (!privateUser || !privateUser.email || !sendToEmail) return const { question } = contract @@ -425,8 +428,10 @@ export const sendNewAnswerEmail = async ( // Don't send the creator's own answers. if (privateUser.id === creatorId) return - const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = - await getDestinationsForUser(privateUser, reason) + const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + reason + ) if (!privateUser.email || !sendToEmail) return const { question, creatorUsername, slug } = contract @@ -465,7 +470,7 @@ export const sendInterestingMarketsEmail = async ( return const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'trending_markets' as keyof notification_subscription_types + 'trending_markets' as notification_preference }` const { name } = user @@ -516,8 +521,10 @@ export const sendNewFollowedMarketEmail = async ( privateUser: PrivateUser, contract: Contract ) => { - const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = - await getDestinationsForUser(privateUser, reason) + const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + reason + ) if (!privateUser.email || !sendToEmail) return const user = await getUser(privateUser.id) if (!user) return @@ -553,8 +560,10 @@ export const sendNewUniqueBettorsEmail = async ( userBets: Dictionary<[Bet, ...Bet[]]>, bonusAmount: number ) => { - const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = - await getDestinationsForUser(privateUser, reason) + const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + reason + ) if (!privateUser.email || !sendToEmail) return const user = await getUser(privateUser.id) if (!user) return diff --git a/functions/src/scripts/create-new-notification-preferences.ts b/functions/src/scripts/create-new-notification-preferences.ts index 2796f2f7..4ba2e25e 100644 --- a/functions/src/scripts/create-new-notification-preferences.ts +++ b/functions/src/scripts/create-new-notification-preferences.ts @@ -1,8 +1,8 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -import { getDefaultNotificationSettings } from 'common/user' import { getAllPrivateUsers, isProd } from 'functions/src/utils' +import { getDefaultNotificationPreferences } from 'common/user-notification-preferences' initAdmin() const firestore = admin.firestore() @@ -17,7 +17,7 @@ async function main() { .collection('private-users') .doc(privateUser.id) .update({ - notificationPreferences: getDefaultNotificationSettings( + notificationPreferences: getDefaultNotificationPreferences( privateUser.id, privateUser, disableEmails diff --git a/functions/src/scripts/create-private-users.ts b/functions/src/scripts/create-private-users.ts index 21e117cf..762e801a 100644 --- a/functions/src/scripts/create-private-users.ts +++ b/functions/src/scripts/create-private-users.ts @@ -3,8 +3,9 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' initAdmin() -import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user' +import { PrivateUser, User } from 'common/user' import { STARTING_BALANCE } from 'common/economy' +import { getDefaultNotificationPreferences } from 'common/user-notification-preferences' const firestore = admin.firestore() @@ -21,7 +22,7 @@ async function main() { id: user.id, email, username, - notificationPreferences: getDefaultNotificationSettings(user.id), + notificationPreferences: getDefaultNotificationPreferences(user.id), } if (user.totalDeposits === undefined) { diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index da7b507f..418282c7 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -1,79 +1,227 @@ import * as admin from 'firebase-admin' import { EndpointDefinition } from './api' -import { getUser } from './utils' +import { getPrivateUser } from './utils' import { PrivateUser } from '../../common/user' +import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification' +import { notification_preference } from '../../common/user-notification-preferences' export const unsubscribe: EndpointDefinition = { opts: { method: 'GET', minInstances: 1 }, handler: async (req, res) => { const id = req.query.id as string - let type = req.query.type as string + const type = req.query.type as string if (!id || !type) { - res.status(400).send('Empty id or type parameter.') + res.status(400).send('Empty id or subscription type parameter.') + return + } + console.log(`Unsubscribing ${id} from ${type}`) + const notificationSubscriptionType = type as notification_preference + if (notificationSubscriptionType === undefined) { + res.status(400).send('Invalid subscription type parameter.') return } - if (type === 'market-resolved') type = 'market-resolve' - - if ( - ![ - 'market-resolve', - 'market-comment', - 'market-answer', - 'generic', - 'weekly-trending', - ].includes(type) - ) { - res.status(400).send('Invalid type parameter.') - return - } - - const user = await getUser(id) + const user = await getPrivateUser(id) if (!user) { res.send('This user is not currently subscribed or does not exist.') return } - const { name } = user + const previousDestinations = + user.notificationPreferences[notificationSubscriptionType] + + console.log(previousDestinations) + const { email } = user const update: Partial = { - ...(type === 'market-resolve' && { - unsubscribedFromResolutionEmails: true, - }), - ...(type === 'market-comment' && { - unsubscribedFromCommentEmails: true, - }), - ...(type === 'market-answer' && { - unsubscribedFromAnswerEmails: true, - }), - ...(type === 'generic' && { - unsubscribedFromGenericEmails: true, - }), - ...(type === 'weekly-trending' && { - unsubscribedFromWeeklyTrendingEmails: true, - }), + notificationPreferences: { + ...user.notificationPreferences, + [notificationSubscriptionType]: previousDestinations.filter( + (destination) => destination !== 'email' + ), + }, } await firestore.collection('private-users').doc(id).update(update) - if (type === 'market-resolve') - res.send( - `${name}, you have been unsubscribed from market resolution emails on Manifold Markets.` - ) - else if (type === 'market-comment') - res.send( - `${name}, you have been unsubscribed from market comment emails on Manifold Markets.` - ) - else if (type === 'market-answer') - res.send( - `${name}, you have been unsubscribed from market answer emails on Manifold Markets.` - ) - else if (type === 'weekly-trending') - res.send( - `${name}, you have been unsubscribed from weekly trending emails on Manifold Markets.` - ) - else res.send(`${name}, you have been unsubscribed.`) + res.send( + ` + + + + + Manifold Markets 7th Day Anniversary Gift! + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+ + banner logo + +
+
+

+ Hello!

+
+
+
+

+ + ${email} has been unsubscribed from email notifications related to: + +
+
+ + ${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}. +

+
+
+
+ Click + here + to manage the rest of your notification settings. + +
+ +
+

+
+
+
+
+
+ + +` + ) }, } diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index d18896bd..8730ce7f 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -1,11 +1,7 @@ import React, { memo, ReactNode, useEffect, useState } from 'react' import { Row } from 'web/components/layout/row' import clsx from 'clsx' -import { - notification_subscription_types, - notification_destination_types, - PrivateUser, -} from 'common/user' +import { PrivateUser } from 'common/user' import { updatePrivateUser } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { @@ -30,6 +26,11 @@ import { usePersistentState, } from 'web/hooks/use-persistent-state' import { safeLocalStorage } from 'web/lib/util/local' +import { NOTIFICATION_DESCRIPTIONS } from 'common/notification' +import { + notification_destination_types, + notification_preference, +} from 'common/user-notification-preferences' export function NotificationSettings(props: { navigateToSection: string | undefined @@ -38,7 +39,7 @@ export function NotificationSettings(props: { const { navigateToSection, privateUser } = props const [showWatchModal, setShowWatchModal] = useState(false) - const emailsEnabled: Array = [ + const emailsEnabled: Array = [ 'all_comments_on_watched_markets', 'all_replies_to_my_comments_on_watched_markets', 'all_comments_on_contracts_with_shares_in_on_watched_markets', @@ -74,7 +75,7 @@ export function NotificationSettings(props: { // 'probability_updates_on_watched_markets', // 'limit_order_fills', ] - const browserDisabled: Array = [ + const browserDisabled: Array = [ 'trending_markets', 'profit_loss_updates', 'onboarding_flow', @@ -83,91 +84,82 @@ export function NotificationSettings(props: { type SectionData = { label: string - subscriptionTypeToDescription: { - [key in keyof Partial]: string - } + subscriptionTypes: Partial[] } const comments: SectionData = { label: 'New Comments', - subscriptionTypeToDescription: { - all_comments_on_watched_markets: 'All new comments', - all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, + subscriptionTypes: [ + 'all_comments_on_watched_markets', + 'all_comments_on_contracts_with_shares_in_on_watched_markets', // TODO: combine these two - all_replies_to_my_comments_on_watched_markets: - 'Only replies to your comments', - all_replies_to_my_answers_on_watched_markets: - 'Only replies to your answers', - // comments_by_followed_users_on_watched_markets: 'By followed users', - }, + 'all_replies_to_my_comments_on_watched_markets', + 'all_replies_to_my_answers_on_watched_markets', + ], } const answers: SectionData = { label: 'New Answers', - subscriptionTypeToDescription: { - all_answers_on_watched_markets: 'All new answers', - all_answers_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, - // answers_by_followed_users_on_watched_markets: 'By followed users', - // answers_by_market_creator_on_watched_markets: 'By market creator', - }, + subscriptionTypes: [ + 'all_answers_on_watched_markets', + 'all_answers_on_contracts_with_shares_in_on_watched_markets', + ], } const updates: SectionData = { label: 'Updates & Resolutions', - subscriptionTypeToDescription: { - market_updates_on_watched_markets: 'All creator updates', - market_updates_on_watched_markets_with_shares_in: `Only creator updates on markets you're invested in`, - resolutions_on_watched_markets: 'All market resolutions', - resolutions_on_watched_markets_with_shares_in: `Only market resolutions you're invested in`, - // probability_updates_on_watched_markets: 'Probability updates', - }, + subscriptionTypes: [ + 'market_updates_on_watched_markets', + 'market_updates_on_watched_markets_with_shares_in', + 'resolutions_on_watched_markets', + 'resolutions_on_watched_markets_with_shares_in', + ], } const yourMarkets: SectionData = { label: 'Markets You Created', - subscriptionTypeToDescription: { - your_contract_closed: 'Your market has closed (and needs resolution)', - all_comments_on_my_markets: 'Comments on your markets', - all_answers_on_my_markets: 'Answers on your markets', - subsidized_your_market: 'Your market was subsidized', - tips_on_your_markets: 'Likes on your markets', - }, + subscriptionTypes: [ + 'your_contract_closed', + 'all_comments_on_my_markets', + 'all_answers_on_my_markets', + 'subsidized_your_market', + 'tips_on_your_markets', + ], } const bonuses: SectionData = { label: 'Bonuses', - subscriptionTypeToDescription: { - betting_streaks: 'Prediction streak bonuses', - referral_bonuses: 'Referral bonuses from referring users', - unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets', - }, + subscriptionTypes: [ + 'betting_streaks', + 'referral_bonuses', + 'unique_bettors_on_your_contract', + ], } const otherBalances: SectionData = { label: 'Other', - subscriptionTypeToDescription: { - loan_income: 'Automatic loans from your profitable bets', - limit_order_fills: 'Limit order fills', - tips_on_your_comments: 'Tips on your comments', - }, + subscriptionTypes: [ + 'loan_income', + 'limit_order_fills', + 'tips_on_your_comments', + ], } const userInteractions: SectionData = { label: 'Users', - subscriptionTypeToDescription: { - tagged_user: 'A user tagged you', - on_new_follow: 'Someone followed you', - contract_from_followed_user: 'New markets created by users you follow', - }, + subscriptionTypes: [ + 'tagged_user', + 'on_new_follow', + 'contract_from_followed_user', + ], } const generalOther: SectionData = { label: 'Other', - subscriptionTypeToDescription: { - trending_markets: 'Weekly interesting markets', - thank_you_for_purchases: 'Thank you notes for your purchases', - onboarding_flow: 'Explanatory emails to help you get started', - // profit_loss_updates: 'Weekly profit/loss updates', - }, + subscriptionTypes: [ + 'trending_markets', + 'thank_you_for_purchases', + 'onboarding_flow', + ], } function NotificationSettingLine(props: { description: string - subscriptionTypeKey: keyof notification_subscription_types + subscriptionTypeKey: notification_preference destinations: notification_destination_types[] }) { const { description, subscriptionTypeKey, destinations } = props @@ -237,9 +229,7 @@ export function NotificationSettings(props: { ) } - const getUsersSavedPreference = ( - key: keyof notification_subscription_types - ) => { + const getUsersSavedPreference = (key: notification_preference) => { return privateUser.notificationPreferences[key] ?? [] } @@ -248,17 +238,17 @@ export function NotificationSettings(props: { data: SectionData }) { const { icon, data } = props - const { label, subscriptionTypeToDescription } = data + const { label, subscriptionTypes } = data const expand = navigateToSection && - Object.keys(subscriptionTypeToDescription).includes(navigateToSection) + Object.keys(subscriptionTypes).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('-'), + Object.keys(subscriptionTypes).join('-'), store: storageStore(safeLocalStorage()), }) @@ -287,13 +277,13 @@ export function NotificationSettings(props: { )} - {Object.entries(subscriptionTypeToDescription).map(([key, value]) => ( + {subscriptionTypes.map((subType) => ( ))} From 9aa56dd19300e084361d14cc606ac690aa434f7d Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 14 Sep 2022 17:25:17 -0600 Subject: [PATCH 15/37] Only show prev opened notif setting section --- web/components/notification-settings.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 8730ce7f..b806dfb2 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -241,14 +241,12 @@ export function NotificationSettings(props: { const { label, subscriptionTypes } = data const expand = navigateToSection && - Object.keys(subscriptionTypes).includes(navigateToSection) + subscriptionTypes.includes(navigateToSection as notification_preference) // Not sure how to prevent re-render (and collapse of an open section) // due to a private user settings change. Just going to persist expanded state here const [expanded, setExpanded] = usePersistentState(expand ?? false, { - key: - 'NotificationsSettingsSection-' + - Object.keys(subscriptionTypes).join('-'), + key: 'NotificationsSettingsSection-' + subscriptionTypes.join('-'), store: storageStore(safeLocalStorage()), }) From ccf02bdba8f565e94fbb01fb9ac5cb7c9de93fc2 Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Wed, 14 Sep 2022 22:28:40 -0500 Subject: [PATCH 16/37] Inga/admin rules resolve (#880) * Giving admin permission to resolve all markets that have closed after 7 days. --- common/envs/constants.ts | 5 ++- common/envs/prod.ts | 1 + firestore.rules | 3 +- functions/src/resolve-market.ts | 9 ++++-- .../answers/answer-resolve-panel.tsx | 20 ++++++++++-- web/components/answers/answers-panel.tsx | 28 ++++++++++------- web/components/numeric-resolution-panel.tsx | 20 +++++++++--- web/components/resolution-panel.tsx | 11 +++++-- web/pages/[username]/[contractSlug].tsx | 31 +++++++++++++++---- 9 files changed, 99 insertions(+), 29 deletions(-) diff --git a/common/envs/constants.ts b/common/envs/constants.ts index ba460d58..0502322a 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -21,7 +21,10 @@ export function isWhitelisted(email?: string) { } // TODO: Before open sourcing, we should turn these into env vars -export function isAdmin(email: string) { +export function isAdmin(email?: string) { + if (!email) { + return false + } return ENV_CONFIG.adminEmails.includes(email) } diff --git a/common/envs/prod.ts b/common/envs/prod.ts index b3b552eb..6bf781b7 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -74,6 +74,7 @@ export const PROD_CONFIG: EnvConfig = { 'iansphilips@gmail.com', // Ian 'd4vidchee@gmail.com', // D4vid 'federicoruizcassarino@gmail.com', // Fede + 'ingawei@gmail.com', //Inga ], visibility: 'PUBLIC', diff --git a/firestore.rules b/firestore.rules index 6f2ea90a..08214b10 100644 --- a/firestore.rules +++ b/firestore.rules @@ -14,7 +14,8 @@ service cloud.firestore { 'manticmarkets@gmail.com', 'iansphilips@gmail.com', 'd4vidchee@gmail.com', - 'federicoruizcassarino@gmail.com' + 'federicoruizcassarino@gmail.com', + 'ingawei@gmail.com' ] } diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index b867b609..44293898 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -16,7 +16,7 @@ import { groupPayoutsByUser, Payout, } from '../../common/payouts' -import { isManifoldId } from '../../common/envs/constants' +import { isAdmin, isManifoldId } from '../../common/envs/constants' import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' import { APIError, newEndpoint, validate } from './api' @@ -76,13 +76,18 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { throw new APIError(404, 'No contract exists with the provided ID') const contract = contractSnap.data() as Contract const { creatorId, closeTime } = contract + const firebaseUser = await admin.auth().getUser(auth.uid) const { value, resolutions, probabilityInt, outcome } = getResolutionParams( contract, req.body ) - if (creatorId !== auth.uid && !isManifoldId(auth.uid)) + if ( + creatorId !== auth.uid && + !isManifoldId(auth.uid) && + !isAdmin(firebaseUser.email) + ) throw new APIError(403, 'User is not creator of contract') if (contract.resolution) throw new APIError(400, 'Contract already resolved') diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx index 0a4ac1e1..4594ea35 100644 --- a/web/components/answers/answer-resolve-panel.tsx +++ b/web/components/answers/answer-resolve-panel.tsx @@ -11,6 +11,8 @@ import { ResolveConfirmationButton } from '../confirmation-button' import { removeUndefinedProps } from 'common/util/object' export function AnswerResolvePanel(props: { + isAdmin: boolean + isCreator: boolean contract: FreeResponseContract | MultipleChoiceContract resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined setResolveOption: ( @@ -18,7 +20,14 @@ export function AnswerResolvePanel(props: { ) => void chosenAnswers: { [answerId: string]: number } }) { - const { contract, resolveOption, setResolveOption, chosenAnswers } = props + const { + contract, + resolveOption, + setResolveOption, + chosenAnswers, + isAdmin, + isCreator, + } = props const answers = Object.keys(chosenAnswers) const [isSubmitting, setIsSubmitting] = useState(false) @@ -76,7 +85,14 @@ export function AnswerResolvePanel(props: { return ( -
Resolve your market
+ +
Resolve your market
+ {isAdmin && !isCreator && ( + + ADMIN + + )} +
)} - {user?.id === creatorId && !resolution && ( - <> - - - - )} + {(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) && + !resolution && ( + <> + + + + )} ) } diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index dce36ab9..70fbf01f 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -12,11 +12,13 @@ import { BucketInput } from './bucket-input' import { getPseudoProbability } from 'common/pseudo-numeric' export function NumericResolutionPanel(props: { + isAdmin: boolean + isCreator: boolean creator: User contract: NumericContract | PseudoNumericContract className?: string }) { - const { contract, className } = props + const { contract, className, isAdmin, isCreator } = props const { min, max, outcomeType } = contract const [outcomeMode, setOutcomeMode] = useState< @@ -78,10 +80,20 @@ export function NumericResolutionPanel(props: { : 'btn-disabled' return ( - -
Resolve market
+ + {isAdmin && !isCreator && ( + + ADMIN + + )} +
Resolve market
-
Outcome
+
Outcome
diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 5a7b993e..6f36331e 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -12,11 +12,13 @@ import { getProbability } from 'common/calculate' import { BinaryContract, resolution } from 'common/contract' export function ResolutionPanel(props: { + isAdmin: boolean + isCreator: boolean creator: User contract: BinaryContract className?: string }) { - const { contract, className } = props + const { contract, className, isAdmin, isCreator } = props // const earnedFees = // contract.mechanism === 'dpm-2' @@ -66,7 +68,12 @@ export function ResolutionPanel(props: { : 'btn-disabled' return ( - + + {isAdmin && !isCreator && ( + + ADMIN + + )}
Resolve market
Outcome
diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index de0c7807..2c011c90 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -45,6 +45,8 @@ import { import { ContractsGrid } from 'web/components/contract/contracts-grid' import { Title } from 'web/components/title' import { usePrefetch } from 'web/hooks/use-prefetch' +import { useAdmin } from 'web/hooks/use-admin' +import dayjs from 'dayjs' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -110,19 +112,28 @@ export default function ContractPage(props: { ) } +// requires an admin to resolve a week after market closes +export function needsAdminToResolve(contract: Contract) { + return !contract.isResolved && dayjs().diff(contract.closeTime, 'day') > 7 +} + export function ContractPageSidebar(props: { user: User | null | undefined contract: Contract }) { const { contract, user } = props const { creatorId, isResolved, outcomeType } = contract - const isCreator = user?.id === creatorId const isBinary = outcomeType === 'BINARY' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isNumeric = outcomeType === 'NUMERIC' const allowTrade = tradingAllowed(contract) - const allowResolve = !isResolved && isCreator && !!user + const isAdmin = useAdmin() + const allowResolve = + !isResolved && + (isCreator || (needsAdminToResolve(contract) && isAdmin)) && + !!user + const hasSidePanel = (isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve) @@ -139,9 +150,19 @@ export function ContractPageSidebar(props: { ))} {allowResolve && (isNumeric || isPseudoNumeric ? ( - + ) : ( - + ))} ) : null @@ -154,10 +175,8 @@ export function ContractPageContent( } ) { const { backToHome, comments, user } = props - const contract = useContractWithPreload(props.contract) ?? props.contract usePrefetch(user?.id) - useTracking( 'view market', { From 8aaaf5e9e05e138d4ec8dbf436ebf594d9fa0792 Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Thu, 15 Sep 2022 01:46:58 -0500 Subject: [PATCH 17/37] Inga/bettingfix (#879) * making betting action panels much more minimal, particularly for mobile * added tiny follow button --- web/components/button.tsx | 5 +- web/components/contract/contract-details.tsx | 252 ++++++++++-------- .../contract/contract-info-dialog.tsx | 11 +- web/components/contract/contract-overview.tsx | 12 +- .../contract/extra-contract-actions-row.tsx | 57 +--- .../contract/like-market-button.tsx | 20 +- web/components/follow-button.tsx | 67 ++++- web/components/follow-market-button.tsx | 16 +- web/pages/[username]/[contractSlug].tsx | 2 - web/tailwind.config.js | 2 + 10 files changed, 262 insertions(+), 182 deletions(-) diff --git a/web/components/button.tsx b/web/components/button.tsx index cb39cba8..ea9a3e88 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -11,6 +11,7 @@ export type ColorType = | 'gray' | 'gradient' | 'gray-white' + | 'highlight-blue' export function Button(props: { className?: string @@ -56,7 +57,9 @@ export function Button(props: { color === 'gradient' && 'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', color === 'gray-white' && - 'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200', + 'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none', + color === 'highlight-blue' && + 'text-highlight-blue border-none shadow-none', className )} disabled={disabled} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index e28ab41a..fad62c86 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -1,9 +1,4 @@ -import { - ClockIcon, - DatabaseIcon, - PencilIcon, - UserGroupIcon, -} from '@heroicons/react/outline' +import { ClockIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { Editor } from '@tiptap/react' import dayjs from 'dayjs' @@ -16,9 +11,8 @@ import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' import { Avatar } from '../avatar' import { useState } from 'react' -import { ContractInfoDialog } from './contract-info-dialog' import NewContractBadge from '../new-contract-badge' -import { UserFollowButton } from '../follow-button' +import { MiniUserFollowButton } from '../follow-button' import { DAY_MS } from 'common/util/time' import { useUser } from 'web/hooks/use-user' import { exhibitExts } from 'common/util/parse' @@ -34,6 +28,8 @@ import { UserLink } from 'web/components/user-link' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' import { Tooltip } from 'web/components/tooltip' import { useWindowSize } from 'web/hooks/use-window-size' +import { ExtraContractActionsRow } from './extra-contract-actions-row' +import { PlusCircleIcon } from '@heroicons/react/solid' export type ShowTime = 'resolve-date' | 'close-date' @@ -104,90 +100,174 @@ export function AvatarDetails(props: { ) } +export function useIsMobile() { + const { width } = useWindowSize() + return (width ?? 0) < 600 +} + export function ContractDetails(props: { contract: Contract disabled?: boolean }) { const { contract, disabled } = props - const { - closeTime, - creatorName, - creatorUsername, - creatorId, - creatorAvatarUrl, - resolutionTime, - } = contract - const { volumeLabel, resolvedDate } = contractMetrics(contract) + const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract + const { resolvedDate } = contractMetrics(contract) const user = useUser() const isCreator = user?.id === creatorId + const isMobile = useIsMobile() + + return ( + + + + {!disabled && ( +
+ +
+ )} + + + {disabled ? ( + creatorName + ) : ( + + )} + + + + {!isMobile && ( + + )} + + +
+ +
+
+ {/* GROUPS */} + {isMobile && ( +
+ +
+ )} + + ) +} + +export function CloseOrResolveTime(props: { + contract: Contract + resolvedDate: any + isCreator: boolean +}) { + const { contract, resolvedDate, isCreator } = props + const { resolutionTime, closeTime } = contract + console.log(closeTime, resolvedDate) + if (!!closeTime || !!resolvedDate) { + return ( + + {resolvedDate && resolutionTime ? ( + <> + + +
resolved 
+ {resolvedDate} +
+
+ + ) : null} + + {!resolvedDate && closeTime && ( + + {dayjs().isBefore(closeTime) &&
closes 
} + {!dayjs().isBefore(closeTime) &&
closed 
} + +
+ )} +
+ ) + } else return <> +} + +export function MarketGroups(props: { + contract: Contract + isMobile: boolean | undefined + disabled: boolean | undefined +}) { const [open, setOpen] = useState(false) - const { width } = useWindowSize() - const isMobile = (width ?? 0) < 600 + const user = useUser() + const { contract, isMobile, disabled } = props const groupToDisplay = getGroupLinkToDisplay(contract) const groupInfo = groupToDisplay ? ( - - {groupToDisplay.name} +
+ {groupToDisplay.name} +
) : ( - - ) - - return ( - - - - {disabled ? ( - creatorName - ) : ( - +
} - - + > + No Group +
+
+ ) + return ( + <> + {disabled ? ( - groupInfo - ) : !groupToDisplay && !user ? ( -
+ { groupInfo } ) : ( {groupInfo} - {user && groupToDisplay && ( - + + )} )} @@ -201,45 +281,7 @@ export function ContractDetails(props: { - - {(!!closeTime || !!resolvedDate) && ( - - {resolvedDate && resolutionTime ? ( - <> - - - {resolvedDate} - - - ) : null} - - {!resolvedDate && closeTime && user && ( - <> - - - - )} - - )} - {user && ( - <> - - -
{volumeLabel}
-
- {!disabled && ( - - )} - - )} - + ) } @@ -280,12 +322,12 @@ export function ExtraMobileContractDetails(props: { !resolvedDate && closeTime && ( + Closes  - Ends ) )} diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index ae586725..07958378 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -18,6 +18,7 @@ import { deleteField } from 'firebase/firestore' import ShortToggle from '../widgets/short-toggle' import { DuplicateContractButton } from '../copy-contract-button' import { Row } from '../layout/row' +import { Button } from '../button' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -67,19 +68,21 @@ export function ContractInfoDialog(props: { return ( <> - + - + <Title className="!mt-0 !mb-0" text="This Market" /> <table className="table-compact table-zebra table w-full text-gray-500"> <tbody> diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 1bfe84de..bfb4829f 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -25,11 +25,11 @@ import { NumericContract, PseudoNumericContract, } from 'common/contract' -import { ContractDetails, ExtraMobileContractDetails } from './contract-details' +import { ContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' const OverviewQuestion = (props: { text: string }) => ( - <Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} /> + <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} /> ) const BetWidget = (props: { contract: CPMMContract }) => { @@ -73,7 +73,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { const { contract, bets } = props return ( <Col className="gap-1 md:gap-2"> - <Col className="gap-3 px-2 sm:gap-4"> + <Col className="gap-1 px-2"> <ContractDetails contract={contract} /> <Row className="justify-between gap-4"> <OverviewQuestion text={contract.question} /> @@ -85,7 +85,6 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { </Row> <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> - <ExtraMobileContractDetails contract={contract} /> {tradingAllowed(contract) && ( <BetWidget contract={contract as CPMMBinaryContract} /> )} @@ -113,10 +112,6 @@ const ChoiceOverview = (props: { </Col> <Col className={'mb-1 gap-y-2'}> <AnswersGraph contract={contract} bets={[...bets].reverse()} /> - <ExtraMobileContractDetails - contract={contract} - forceShowVolume={true} - /> </Col> </Col> ) @@ -140,7 +135,6 @@ const PseudoNumericOverview = (props: { </Row> <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> - <ExtraMobileContractDetails contract={contract} /> {tradingAllowed(contract) && <BetWidget contract={contract} />} </Row> </Col> diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index 5d5ee4d8..af5db9c3 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -11,38 +11,29 @@ import { FollowMarketButton } from 'web/components/follow-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button' import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' import { Col } from 'web/components/layout/col' -import { withTracking } from 'web/lib/service/analytics' -import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' -import { CHALLENGES_ENABLED } from 'common/challenge' -import ChallengeIcon from 'web/lib/icons/challenge-icon' export function ExtraContractActionsRow(props: { contract: Contract }) { const { contract } = props - const { outcomeType, resolution } = contract const user = useUser() const [isShareOpen, setShareOpen] = useState(false) - const [openCreateChallengeModal, setOpenCreateChallengeModal] = - useState(false) - const showChallenge = - user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED return ( - <Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}> + <Row> + <FollowMarketButton contract={contract} user={user} /> + {user?.id !== contract.creatorId && ( + <LikeMarketButton contract={contract} user={user} /> + )} <Button - size="lg" + size="sm" color="gray-white" className={'flex'} onClick={() => { setShareOpen(true) }} > - <Col className={'items-center sm:flex-row'}> - <ShareIcon - className={clsx('h-[24px] w-5 sm:mr-2')} - aria-hidden="true" - /> - <span>Share</span> - </Col> + <Row> + <ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" /> + </Row> <ShareModal isOpen={isShareOpen} setOpen={setShareOpen} @@ -50,35 +41,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { user={user} /> </Button> - - {showChallenge && ( - <Button - size="lg" - color="gray-white" - className="max-w-xs self-center" - onClick={withTracking( - () => setOpenCreateChallengeModal(true), - 'click challenge button' - )} - > - <Col className="items-center sm:flex-row"> - <ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" /> - <span>Challenge</span> - </Col> - <CreateChallengeModal - isOpen={openCreateChallengeModal} - setOpen={setOpenCreateChallengeModal} - user={user} - contract={contract} - /> - </Button> - )} - - <FollowMarketButton contract={contract} user={user} /> - {user?.id !== contract.creatorId && ( - <LikeMarketButton contract={contract} user={user} /> - )} - <Col className={'justify-center md:hidden'}> + <Col className={'justify-center'}> <ContractInfoDialog contract={contract} /> </Col> </Row> diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx index e35e3e7e..01dce32f 100644 --- a/web/components/contract/like-market-button.tsx +++ b/web/components/contract/like-market-button.tsx @@ -38,15 +38,16 @@ export function LikeMarketButton(props: { return ( <Button - size={'lg'} + size={'sm'} className={'max-w-xs self-center'} color={'gray-white'} onClick={onLike} > - <Col className={'items-center sm:flex-row'}> + <Col className={'relative items-center sm:flex-row'}> <HeartIcon className={clsx( - 'h-[24px] w-5 sm:mr-2', + 'h-5 w-5 sm:h-6 sm:w-6', + totalTipped > 0 ? 'mr-2' : '', user && (userLikedContractIds?.includes(contract.id) || (!likes && contract.likedByUserIds?.includes(user.id))) @@ -54,7 +55,18 @@ export function LikeMarketButton(props: { : '' )} /> - Tip {totalTipped > 0 ? `(${formatMoney(totalTipped)})` : ''} + {totalTipped > 0 && ( + <div + className={clsx( + 'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1', + totalTipped > 99 + ? 'text-[0.4rem] sm:text-[0.5rem]' + : 'sm:text-2xs text-[0.5rem]' + )} + > + {totalTipped} + </div> + )} </Col> </Button> ) diff --git a/web/components/follow-button.tsx b/web/components/follow-button.tsx index 09495169..6344757d 100644 --- a/web/components/follow-button.tsx +++ b/web/components/follow-button.tsx @@ -1,4 +1,6 @@ +import { CheckCircleIcon, PlusCircleIcon } from '@heroicons/react/solid' import clsx from 'clsx' +import { useEffect, useRef, useState } from 'react' import { useFollows } from 'web/hooks/use-follows' import { useUser } from 'web/hooks/use-user' import { follow, unfollow } from 'web/lib/firebase/users' @@ -54,18 +56,73 @@ export function FollowButton(props: { export function UserFollowButton(props: { userId: string; small?: boolean }) { const { userId, small } = props - const currentUser = useUser() - const following = useFollows(currentUser?.id) + const user = useUser() + const following = useFollows(user?.id) const isFollowing = following?.includes(userId) - if (!currentUser || currentUser.id === userId) return null + if (!user || user.id === userId) return null return ( <FollowButton isFollowing={isFollowing} - onFollow={() => follow(currentUser.id, userId)} - onUnfollow={() => unfollow(currentUser.id, userId)} + onFollow={() => follow(user.id, userId)} + onUnfollow={() => unfollow(user.id, userId)} small={small} /> ) } + +export function MiniUserFollowButton(props: { userId: string }) { + const { userId } = props + const user = useUser() + const following = useFollows(user?.id) + const isFollowing = following?.includes(userId) + const isFirstRender = useRef(true) + const [justFollowed, setJustFollowed] = useState(false) + + useEffect(() => { + if (isFirstRender.current) { + if (isFollowing != undefined) { + isFirstRender.current = false + } + return + } + if (isFollowing) { + setJustFollowed(true) + setTimeout(() => { + setJustFollowed(false) + }, 1000) + } + }, [isFollowing]) + + if (justFollowed) { + return ( + <CheckCircleIcon + className={clsx( + 'text-highlight-blue ml-3 mt-2 h-5 w-5 rounded-full bg-white sm:mr-2' + )} + aria-hidden="true" + /> + ) + } + if ( + !user || + user.id === userId || + isFollowing || + !user || + isFollowing === undefined + ) + return null + return ( + <> + <button onClick={withTracking(() => follow(user.id, userId), 'follow')}> + <PlusCircleIcon + className={clsx( + 'text-highlight-blue hover:text-hover-blue mt-2 ml-3 h-5 w-5 rounded-full bg-white sm:mr-2' + )} + aria-hidden="true" + /> + </button> + </> + ) +} diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 1dd261cb..0e65165b 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -25,7 +25,7 @@ export const FollowMarketButton = (props: { return ( <Button - size={'lg'} + size={'sm'} color={'gray-white'} onClick={async () => { if (!user) return firebaseLogin() @@ -56,13 +56,19 @@ export const FollowMarketButton = (props: { > {followers?.includes(user?.id ?? 'nope') ? ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" /> - Unwatch + <EyeOffIcon + className={clsx('h-5 w-5 sm:h-6 sm:w-6')} + aria-hidden="true" + /> + {/* Unwatch */} </Col> ) : ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" /> - Watch + <EyeIcon + className={clsx('h-5 w-5 sm:h-6 sm:w-6')} + aria-hidden="true" + /> + {/* Watch */} </Col> )} <WatchMarketModal diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 2c011c90..a0b2ed50 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -37,7 +37,6 @@ import { User } from 'common/user' import { ContractComment } from 'common/comment' import { getOpenGraphProps } from 'common/contract-details' import { ContractDescription } from 'web/components/contract/contract-description' -import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row' import { ContractLeaderboard, ContractTopTrades, @@ -257,7 +256,6 @@ export function ContractPageContent( )} <ContractOverview contract={contract} bets={nonChallengeBets} /> - <ExtraContractActionsRow contract={contract} /> <ContractDescription className="mb-6 px-2" contract={contract} /> {outcomeType === 'NUMERIC' && ( diff --git a/web/tailwind.config.js b/web/tailwind.config.js index eb411216..7bea3ec2 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -26,6 +26,8 @@ module.exports = { 'greyscale-5': '#9191A7', 'greyscale-6': '#66667C', 'greyscale-7': '#111140', + 'highlight-blue': '#5BCEFF', + 'hover-blue': '#90DEFF', }, typography: { quoteless: { From 176acf959fe9fe092dfd84fb446062bf916e5734 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Thu, 15 Sep 2022 13:55:57 +0100 Subject: [PATCH 18/37] Revert "Inga/bettingfix (#879)" This reverts commit 8aaaf5e9e05e138d4ec8dbf436ebf594d9fa0792. --- web/components/button.tsx | 5 +- web/components/contract/contract-details.tsx | 250 ++++++++---------- .../contract/contract-info-dialog.tsx | 11 +- web/components/contract/contract-overview.tsx | 12 +- .../contract/extra-contract-actions-row.tsx | 57 +++- .../contract/like-market-button.tsx | 20 +- web/components/follow-button.tsx | 67 +---- web/components/follow-market-button.tsx | 16 +- web/pages/[username]/[contractSlug].tsx | 2 + web/tailwind.config.js | 2 - 10 files changed, 181 insertions(+), 261 deletions(-) diff --git a/web/components/button.tsx b/web/components/button.tsx index ea9a3e88..cb39cba8 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -11,7 +11,6 @@ export type ColorType = | 'gray' | 'gradient' | 'gray-white' - | 'highlight-blue' export function Button(props: { className?: string @@ -57,9 +56,7 @@ export function Button(props: { color === 'gradient' && 'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', color === 'gray-white' && - 'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none', - color === 'highlight-blue' && - 'text-highlight-blue border-none shadow-none', + 'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200', className )} disabled={disabled} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index fad62c86..e28ab41a 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -1,4 +1,9 @@ -import { ClockIcon } from '@heroicons/react/outline' +import { + ClockIcon, + DatabaseIcon, + PencilIcon, + UserGroupIcon, +} from '@heroicons/react/outline' import clsx from 'clsx' import { Editor } from '@tiptap/react' import dayjs from 'dayjs' @@ -11,8 +16,9 @@ import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' import { Avatar } from '../avatar' import { useState } from 'react' +import { ContractInfoDialog } from './contract-info-dialog' import NewContractBadge from '../new-contract-badge' -import { MiniUserFollowButton } from '../follow-button' +import { UserFollowButton } from '../follow-button' import { DAY_MS } from 'common/util/time' import { useUser } from 'web/hooks/use-user' import { exhibitExts } from 'common/util/parse' @@ -28,8 +34,6 @@ import { UserLink } from 'web/components/user-link' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' import { Tooltip } from 'web/components/tooltip' import { useWindowSize } from 'web/hooks/use-window-size' -import { ExtraContractActionsRow } from './extra-contract-actions-row' -import { PlusCircleIcon } from '@heroicons/react/solid' export type ShowTime = 'resolve-date' | 'close-date' @@ -100,174 +104,90 @@ export function AvatarDetails(props: { ) } -export function useIsMobile() { - const { width } = useWindowSize() - return (width ?? 0) < 600 -} - export function ContractDetails(props: { contract: Contract disabled?: boolean }) { const { contract, disabled } = props - const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract - const { resolvedDate } = contractMetrics(contract) + const { + closeTime, + creatorName, + creatorUsername, + creatorId, + creatorAvatarUrl, + resolutionTime, + } = contract + const { volumeLabel, resolvedDate } = contractMetrics(contract) const user = useUser() const isCreator = user?.id === creatorId - const isMobile = useIsMobile() - - return ( - <Col> - <Row> - <Avatar - username={creatorUsername} - avatarUrl={creatorAvatarUrl} - noLink={disabled} - size={9} - className="mr-1.5" - /> - {!disabled && ( - <div className="absolute mt-3 ml-[11px]"> - <MiniUserFollowButton userId={creatorId} /> - </div> - )} - <Col className="text-greyscale-6 ml-2 flex-1 flex-wrap text-sm"> - <Row className="w-full justify-between "> - {disabled ? ( - creatorName - ) : ( - <UserLink - className="my-auto whitespace-nowrap" - name={creatorName} - username={creatorUsername} - short={isMobile} - /> - )} - </Row> - <Row className="text-2xs text-greyscale-4 gap-2 sm:text-xs"> - <CloseOrResolveTime - contract={contract} - resolvedDate={resolvedDate} - isCreator={isCreator} - /> - {!isMobile && ( - <MarketGroups - contract={contract} - isMobile={isMobile} - disabled={disabled} - /> - )} - </Row> - </Col> - <div className="mt-0"> - <ExtraContractActionsRow contract={contract} /> - </div> - </Row> - {/* GROUPS */} - {isMobile && ( - <div className="mt-2"> - <MarketGroups - contract={contract} - isMobile={isMobile} - disabled={disabled} - /> - </div> - )} - </Col> - ) -} - -export function CloseOrResolveTime(props: { - contract: Contract - resolvedDate: any - isCreator: boolean -}) { - const { contract, resolvedDate, isCreator } = props - const { resolutionTime, closeTime } = contract - console.log(closeTime, resolvedDate) - if (!!closeTime || !!resolvedDate) { - return ( - <Row className="select-none items-center gap-1"> - {resolvedDate && resolutionTime ? ( - <> - <DateTimeTooltip text="Market resolved:" time={resolutionTime}> - <Row> - <div>resolved </div> - {resolvedDate} - </Row> - </DateTimeTooltip> - </> - ) : null} - - {!resolvedDate && closeTime && ( - <Row> - {dayjs().isBefore(closeTime) && <div>closes </div>} - {!dayjs().isBefore(closeTime) && <div>closed </div>} - <EditableCloseDate - closeTime={closeTime} - contract={contract} - isCreator={isCreator ?? false} - /> - </Row> - )} - </Row> - ) - } else return <></> -} - -export function MarketGroups(props: { - contract: Contract - isMobile: boolean | undefined - disabled: boolean | undefined -}) { const [open, setOpen] = useState(false) - const user = useUser() - const { contract, isMobile, disabled } = props + const { width } = useWindowSize() + const isMobile = (width ?? 0) < 600 const groupToDisplay = getGroupLinkToDisplay(contract) const groupInfo = groupToDisplay ? ( <Link prefetch={false} href={groupPath(groupToDisplay.slug)}> <a className={clsx( - 'flex flex-row items-center truncate pr-1', + linkClass, + 'flex flex-row items-center truncate pr-0 sm:pr-2', isMobile ? 'max-w-[140px]' : 'max-w-[250px]' )} > - <div className="bg-greyscale-4 hover:bg-greyscale-3 text-2xs items-center truncate rounded-full px-2 text-white sm:text-xs"> - {groupToDisplay.name} - </div> + <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> + <span className="items-center truncate">{groupToDisplay.name}</span> </a> </Link> ) : ( - <Row - className={clsx( - 'cursor-default select-none items-center truncate pr-1', - isMobile ? 'max-w-[140px]' : 'max-w-[250px]' - )} + <Button + size={'xs'} + className={'max-w-[200px] pr-2'} + color={'gray-white'} + onClick={() => !groupToDisplay && setOpen(true)} > - <div - className={clsx( - 'bg-greyscale-4 text-2xs items-center truncate rounded-full px-2 text-white sm:text-xs' - )} - > - No Group - </div> - </Row> + <Row> + <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> + <span className="truncate">No Group</span> + </Row> + </Button> ) + return ( - <> - <Row className="align-middle"> + <Row className="flex-1 flex-wrap items-center gap-2 text-sm text-gray-500 md:gap-x-4 md:gap-y-2"> + <Row className="items-center gap-2"> + <Avatar + username={creatorUsername} + avatarUrl={creatorAvatarUrl} + noLink={disabled} + size={6} + /> {disabled ? ( - { groupInfo } + creatorName + ) : ( + <UserLink + className="whitespace-nowrap" + name={creatorName} + username={creatorUsername} + short={isMobile} + /> + )} + {!disabled && <UserFollowButton userId={creatorId} small />} + </Row> + <Row> + {disabled ? ( + groupInfo + ) : !groupToDisplay && !user ? ( + <div /> ) : ( <Row> {groupInfo} - {user && ( - <button - className="text-greyscale-4 hover:text-greyscale-3" + {user && groupToDisplay && ( + <Button + size={'xs'} + color={'gray-white'} onClick={() => setOpen(!open)} > - <PlusCircleIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" /> - </button> + <PencilIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" /> + </Button> )} </Row> )} @@ -281,7 +201,45 @@ export function MarketGroups(props: { <ContractGroupsList contract={contract} user={user} /> </Col> </Modal> - </> + + {(!!closeTime || !!resolvedDate) && ( + <Row className="hidden items-center gap-1 md:inline-flex"> + {resolvedDate && resolutionTime ? ( + <> + <ClockIcon className="h-5 w-5" /> + <DateTimeTooltip text="Market resolved:" time={resolutionTime}> + {resolvedDate} + </DateTimeTooltip> + </> + ) : null} + + {!resolvedDate && closeTime && user && ( + <> + <ClockIcon className="h-5 w-5" /> + <EditableCloseDate + closeTime={closeTime} + contract={contract} + isCreator={isCreator ?? false} + /> + </> + )} + </Row> + )} + {user && ( + <> + <Row className="hidden items-center gap-1 md:inline-flex"> + <DatabaseIcon className="h-5 w-5" /> + <div className="whitespace-nowrap">{volumeLabel}</div> + </Row> + {!disabled && ( + <ContractInfoDialog + contract={contract} + className={'hidden md:inline-flex'} + /> + )} + </> + )} + </Row> ) } @@ -322,12 +280,12 @@ export function ExtraMobileContractDetails(props: { !resolvedDate && closeTime && ( <Col className={'items-center text-sm text-gray-500'}> - <Row className={'text-gray-400'}>Closes </Row> <EditableCloseDate closeTime={closeTime} contract={contract} isCreator={creatorId === user?.id} /> + <Row className={'text-gray-400'}>Ends</Row> </Col> ) )} diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 07958378..ae586725 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -18,7 +18,6 @@ import { deleteField } from 'firebase/firestore' import ShortToggle from '../widgets/short-toggle' import { DuplicateContractButton } from '../copy-contract-button' import { Row } from '../layout/row' -import { Button } from '../button' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -68,21 +67,19 @@ export function ContractInfoDialog(props: { return ( <> - <Button - size="sm" - color="gray-white" + <button className={clsx(contractDetailsButtonClassName, className)} onClick={() => setOpen(true)} > <DotsHorizontalIcon - className={clsx('h-5 w-5 flex-shrink-0')} + className={clsx('h-6 w-6 flex-shrink-0')} aria-hidden="true" /> - </Button> + </button> <Modal open={open} setOpen={setOpen}> <Col className="gap-4 rounded bg-white p-6"> - <Title className="!mt-0 !mb-0" text="This Market" /> + <Title className="!mt-0 !mb-0" text="Market info" /> <table className="table-compact table-zebra table w-full text-gray-500"> <tbody> diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index bfb4829f..1bfe84de 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -25,11 +25,11 @@ import { NumericContract, PseudoNumericContract, } from 'common/contract' -import { ContractDetails } from './contract-details' +import { ContractDetails, ExtraMobileContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' const OverviewQuestion = (props: { text: string }) => ( - <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} /> + <Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} /> ) const BetWidget = (props: { contract: CPMMContract }) => { @@ -73,7 +73,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { const { contract, bets } = props return ( <Col className="gap-1 md:gap-2"> - <Col className="gap-1 px-2"> + <Col className="gap-3 px-2 sm:gap-4"> <ContractDetails contract={contract} /> <Row className="justify-between gap-4"> <OverviewQuestion text={contract.question} /> @@ -85,6 +85,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { </Row> <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> + <ExtraMobileContractDetails contract={contract} /> {tradingAllowed(contract) && ( <BetWidget contract={contract as CPMMBinaryContract} /> )} @@ -112,6 +113,10 @@ const ChoiceOverview = (props: { </Col> <Col className={'mb-1 gap-y-2'}> <AnswersGraph contract={contract} bets={[...bets].reverse()} /> + <ExtraMobileContractDetails + contract={contract} + forceShowVolume={true} + /> </Col> </Col> ) @@ -135,6 +140,7 @@ const PseudoNumericOverview = (props: { </Row> <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> + <ExtraMobileContractDetails contract={contract} /> {tradingAllowed(contract) && <BetWidget contract={contract} />} </Row> </Col> diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index af5db9c3..5d5ee4d8 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -11,29 +11,38 @@ import { FollowMarketButton } from 'web/components/follow-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button' import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' import { Col } from 'web/components/layout/col' +import { withTracking } from 'web/lib/service/analytics' +import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' +import { CHALLENGES_ENABLED } from 'common/challenge' +import ChallengeIcon from 'web/lib/icons/challenge-icon' export function ExtraContractActionsRow(props: { contract: Contract }) { const { contract } = props + const { outcomeType, resolution } = contract const user = useUser() const [isShareOpen, setShareOpen] = useState(false) + const [openCreateChallengeModal, setOpenCreateChallengeModal] = + useState(false) + const showChallenge = + user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED return ( - <Row> - <FollowMarketButton contract={contract} user={user} /> - {user?.id !== contract.creatorId && ( - <LikeMarketButton contract={contract} user={user} /> - )} + <Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}> <Button - size="sm" + size="lg" color="gray-white" className={'flex'} onClick={() => { setShareOpen(true) }} > - <Row> - <ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" /> - </Row> + <Col className={'items-center sm:flex-row'}> + <ShareIcon + className={clsx('h-[24px] w-5 sm:mr-2')} + aria-hidden="true" + /> + <span>Share</span> + </Col> <ShareModal isOpen={isShareOpen} setOpen={setShareOpen} @@ -41,7 +50,35 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { user={user} /> </Button> - <Col className={'justify-center'}> + + {showChallenge && ( + <Button + size="lg" + color="gray-white" + className="max-w-xs self-center" + onClick={withTracking( + () => setOpenCreateChallengeModal(true), + 'click challenge button' + )} + > + <Col className="items-center sm:flex-row"> + <ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" /> + <span>Challenge</span> + </Col> + <CreateChallengeModal + isOpen={openCreateChallengeModal} + setOpen={setOpenCreateChallengeModal} + user={user} + contract={contract} + /> + </Button> + )} + + <FollowMarketButton contract={contract} user={user} /> + {user?.id !== contract.creatorId && ( + <LikeMarketButton contract={contract} user={user} /> + )} + <Col className={'justify-center md:hidden'}> <ContractInfoDialog contract={contract} /> </Col> </Row> diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx index 01dce32f..e35e3e7e 100644 --- a/web/components/contract/like-market-button.tsx +++ b/web/components/contract/like-market-button.tsx @@ -38,16 +38,15 @@ export function LikeMarketButton(props: { return ( <Button - size={'sm'} + size={'lg'} className={'max-w-xs self-center'} color={'gray-white'} onClick={onLike} > - <Col className={'relative items-center sm:flex-row'}> + <Col className={'items-center sm:flex-row'}> <HeartIcon className={clsx( - 'h-5 w-5 sm:h-6 sm:w-6', - totalTipped > 0 ? 'mr-2' : '', + 'h-[24px] w-5 sm:mr-2', user && (userLikedContractIds?.includes(contract.id) || (!likes && contract.likedByUserIds?.includes(user.id))) @@ -55,18 +54,7 @@ export function LikeMarketButton(props: { : '' )} /> - {totalTipped > 0 && ( - <div - className={clsx( - 'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1', - totalTipped > 99 - ? 'text-[0.4rem] sm:text-[0.5rem]' - : 'sm:text-2xs text-[0.5rem]' - )} - > - {totalTipped} - </div> - )} + Tip {totalTipped > 0 ? `(${formatMoney(totalTipped)})` : ''} </Col> </Button> ) diff --git a/web/components/follow-button.tsx b/web/components/follow-button.tsx index 6344757d..09495169 100644 --- a/web/components/follow-button.tsx +++ b/web/components/follow-button.tsx @@ -1,6 +1,4 @@ -import { CheckCircleIcon, PlusCircleIcon } from '@heroicons/react/solid' import clsx from 'clsx' -import { useEffect, useRef, useState } from 'react' import { useFollows } from 'web/hooks/use-follows' import { useUser } from 'web/hooks/use-user' import { follow, unfollow } from 'web/lib/firebase/users' @@ -56,73 +54,18 @@ export function FollowButton(props: { export function UserFollowButton(props: { userId: string; small?: boolean }) { const { userId, small } = props - const user = useUser() - const following = useFollows(user?.id) + const currentUser = useUser() + const following = useFollows(currentUser?.id) const isFollowing = following?.includes(userId) - if (!user || user.id === userId) return null + if (!currentUser || currentUser.id === userId) return null return ( <FollowButton isFollowing={isFollowing} - onFollow={() => follow(user.id, userId)} - onUnfollow={() => unfollow(user.id, userId)} + onFollow={() => follow(currentUser.id, userId)} + onUnfollow={() => unfollow(currentUser.id, userId)} small={small} /> ) } - -export function MiniUserFollowButton(props: { userId: string }) { - const { userId } = props - const user = useUser() - const following = useFollows(user?.id) - const isFollowing = following?.includes(userId) - const isFirstRender = useRef(true) - const [justFollowed, setJustFollowed] = useState(false) - - useEffect(() => { - if (isFirstRender.current) { - if (isFollowing != undefined) { - isFirstRender.current = false - } - return - } - if (isFollowing) { - setJustFollowed(true) - setTimeout(() => { - setJustFollowed(false) - }, 1000) - } - }, [isFollowing]) - - if (justFollowed) { - return ( - <CheckCircleIcon - className={clsx( - 'text-highlight-blue ml-3 mt-2 h-5 w-5 rounded-full bg-white sm:mr-2' - )} - aria-hidden="true" - /> - ) - } - if ( - !user || - user.id === userId || - isFollowing || - !user || - isFollowing === undefined - ) - return null - return ( - <> - <button onClick={withTracking(() => follow(user.id, userId), 'follow')}> - <PlusCircleIcon - className={clsx( - 'text-highlight-blue hover:text-hover-blue mt-2 ml-3 h-5 w-5 rounded-full bg-white sm:mr-2' - )} - aria-hidden="true" - /> - </button> - </> - ) -} diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 0e65165b..1dd261cb 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -25,7 +25,7 @@ export const FollowMarketButton = (props: { return ( <Button - size={'sm'} + size={'lg'} color={'gray-white'} onClick={async () => { if (!user) return firebaseLogin() @@ -56,19 +56,13 @@ export const FollowMarketButton = (props: { > {followers?.includes(user?.id ?? 'nope') ? ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeOffIcon - className={clsx('h-5 w-5 sm:h-6 sm:w-6')} - aria-hidden="true" - /> - {/* Unwatch */} + <EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" /> + Unwatch </Col> ) : ( <Col className={'items-center gap-x-2 sm:flex-row'}> - <EyeIcon - className={clsx('h-5 w-5 sm:h-6 sm:w-6')} - aria-hidden="true" - /> - {/* Watch */} + <EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" /> + Watch </Col> )} <WatchMarketModal diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index a0b2ed50..2c011c90 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -37,6 +37,7 @@ import { User } from 'common/user' import { ContractComment } from 'common/comment' import { getOpenGraphProps } from 'common/contract-details' import { ContractDescription } from 'web/components/contract/contract-description' +import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row' import { ContractLeaderboard, ContractTopTrades, @@ -256,6 +257,7 @@ export function ContractPageContent( )} <ContractOverview contract={contract} bets={nonChallengeBets} /> + <ExtraContractActionsRow contract={contract} /> <ContractDescription className="mb-6 px-2" contract={contract} /> {outcomeType === 'NUMERIC' && ( diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 7bea3ec2..eb411216 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -26,8 +26,6 @@ module.exports = { 'greyscale-5': '#9191A7', 'greyscale-6': '#66667C', 'greyscale-7': '#111140', - 'highlight-blue': '#5BCEFF', - 'hover-blue': '#90DEFF', }, typography: { quoteless: { From e5428ce52540e0b31eb67bd548fa038d00ca5fcd Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 07:14:59 -0600 Subject: [PATCH 19/37] Watch market modal copy --- web/components/contract/watch-market-modal.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/web/components/contract/watch-market-modal.tsx b/web/components/contract/watch-market-modal.tsx index 2fb9bc00..8f79e1ed 100644 --- a/web/components/contract/watch-market-modal.tsx +++ b/web/components/contract/watch-market-modal.tsx @@ -18,21 +18,22 @@ export const WatchMarketModal = (props: { <Col className={'gap-2'}> <span className={'text-indigo-700'}>• What is watching?</span> <span className={'ml-2'}> - You'll receive notifications on markets by betting, commenting, or - clicking the + Watching a market means you'll receive notifications from activity + on it. You automatically start watching a market if you comment on + it, bet on it, or click the <EyeIcon className={clsx('ml-1 inline h-6 w-6 align-top')} aria-hidden="true" /> - ️ button on them. + ️ button. </span> <span className={'text-indigo-700'}> • What types of notifications will I receive? </span> <span className={'ml-2'}> - You'll receive notifications for new comments, answers, and updates - to the question. See the notifications settings pages to customize - which types of notifications you receive on watched markets. + New comments, answers, and updates to the question. See the + notifications settings pages to customize which types of + notifications you receive on watched markets. </span> </Col> </Col> From 4a5c6a42f67fdd1610bf26190837ef9e7c560e15 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 07:45:11 -0600 Subject: [PATCH 20/37] Store bonus txn data in data field --- common/txn.ts | 29 ++++++++++++++-- functions/src/on-create-bet.ts | 4 ++- .../scripts/update-bonus-txn-data-fields.ts | 34 +++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 functions/src/scripts/update-bonus-txn-data-fields.ts diff --git a/common/txn.ts b/common/txn.ts index 00b19570..713d4a38 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -1,6 +1,12 @@ // A txn (pronounced "texan") respresents a payment between two ids on Manifold // Shortened from "transaction" to distinguish from Firebase transactions (and save chars) -type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus +type AnyTxnType = + | Donation + | Tip + | Manalink + | Referral + | UniqueBettorBonus + | BettingStreakBonus type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn<T extends AnyTxnType = AnyTxnType> = { @@ -60,10 +66,27 @@ type Referral = { category: 'REFERRAL' } -type Bonus = { +type UniqueBettorBonus = { fromType: 'BANK' toType: 'USER' - category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' + category: 'UNIQUE_BETTOR_BONUS' + // This data was mistakenly stored as a stringified JSON object in description previously + data: { + contractId: string + uniqueNewBettorId?: string + // Previously stored all unique bettor ids in description + uniqueBettorIds?: string[] + } +} + +type BettingStreakBonus = { + fromType: 'BANK' + toType: 'USER' + category: 'BETTING_STREAK_BONUS' + // This data was mistakenly stored as a stringified JSON object in description previously + data: { + currentBettingStreak?: number + } } export type DonationTxn = Txn & Donation diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 5fe3fd62..7f4ca067 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -119,6 +119,7 @@ const updateBettingStreak = async ( token: 'M$', category: 'BETTING_STREAK_BONUS', description: JSON.stringify(bonusTxnDetails), + data: bonusTxnDetails, } return await runTxn(trans, bonusTxn) }) @@ -186,7 +187,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( // Create combined txn for all new unique bettors const bonusTxnDetails = { contractId: contract.id, - uniqueBettorIds: newUniqueBettorIds, + uniqueNewBettorId: bettor.id, } const fromUserId = isProd() ? HOUSE_LIQUIDITY_PROVIDER_ID @@ -204,6 +205,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( token: 'M$', category: 'UNIQUE_BETTOR_BONUS', description: JSON.stringify(bonusTxnDetails), + data: bonusTxnDetails, } return await runTxn(trans, bonusTxn) }) diff --git a/functions/src/scripts/update-bonus-txn-data-fields.ts b/functions/src/scripts/update-bonus-txn-data-fields.ts new file mode 100644 index 00000000..82955fa0 --- /dev/null +++ b/functions/src/scripts/update-bonus-txn-data-fields.ts @@ -0,0 +1,34 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { Txn } from 'common/txn' +import { getValues } from 'functions/src/utils' + +initAdmin() + +const firestore = admin.firestore() + +async function main() { + // get all txns + const bonusTxns = await getValues<Txn>( + firestore + .collection('txns') + .where('category', 'in', ['UNIQUE_BETTOR_BONUS', 'BETTING_STREAK_BONUS']) + ) + // JSON parse description field and add to data field + const updatedTxns = bonusTxns.map((txn) => { + txn.data = txn.description && JSON.parse(txn.description) + return txn + }) + console.log('updatedTxns', updatedTxns[0]) + // update txns + await Promise.all( + updatedTxns.map((txn) => { + return firestore.collection('txns').doc(txn.id).update({ + data: txn.data, + }) + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) From 733d2065178aec2e7de32e658c194d2b04e26bbe Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 07:50:35 -0600 Subject: [PATCH 21/37] Add txn types --- common/txn.ts | 2 ++ functions/src/on-create-bet.ts | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/common/txn.ts b/common/txn.ts index 713d4a38..ac3b76de 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -93,3 +93,5 @@ export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink export type ReferralTxn = Txn & Referral +export type BettingStreakBonusTxn = Txn & BettingStreakBonus +export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 7f4ca067..b645e3b7 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -27,6 +27,7 @@ import { User } from '../../common/user' import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes' import { addHouseLiquidity } from './add-liquidity' import { DAY_MS } from '../../common/util/time' +import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn' const firestore = admin.firestore() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() @@ -109,6 +110,7 @@ const updateBettingStreak = async ( const bonusTxnDetails = { currentBettingStreak: newBettingStreak, } + // TODO: set the id of the txn to the eventId to prevent duplicates const result = await firestore.runTransaction(async (trans) => { const bonusTxn: TxnData = { fromId: fromUserId, @@ -120,7 +122,7 @@ const updateBettingStreak = async ( category: 'BETTING_STREAK_BONUS', description: JSON.stringify(bonusTxnDetails), data: bonusTxnDetails, - } + } as Omit<BettingStreakBonusTxn, 'id' | 'createdTime'> return await runTxn(trans, bonusTxn) }) if (!result.txn) { @@ -195,6 +197,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( const fromSnap = await firestore.doc(`users/${fromUserId}`).get() if (!fromSnap.exists) throw new APIError(400, 'From user not found.') const fromUser = fromSnap.data() as User + // TODO: set the id of the txn to the eventId to prevent duplicates const result = await firestore.runTransaction(async (trans) => { const bonusTxn: TxnData = { fromId: fromUser.id, @@ -206,7 +209,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( category: 'UNIQUE_BETTOR_BONUS', description: JSON.stringify(bonusTxnDetails), data: bonusTxnDetails, - } + } as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'> return await runTxn(trans, bonusTxn) }) From ada9fac343de92313e451e79a823d9319089f55f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 08:07:42 -0600 Subject: [PATCH 22/37] Add logs to on-create-bet --- functions/src/on-create-bet.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index b645e3b7..ce75f0fe 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -127,6 +127,8 @@ const updateBettingStreak = async ( }) if (!result.txn) { log("betting streak bonus txn couldn't be made") + log('status:', result.status) + log('message:', result.message) return } @@ -214,7 +216,8 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( }) if (result.status != 'success' || !result.txn) { - log(`No bonus for user: ${contract.creatorId} - reason:`, result.status) + log(`No bonus for user: ${contract.creatorId} - status:`, result.status) + log('message:', result.message) } else { log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id) await createUniqueBettorBonusNotification( From 772eeb5c93de41b7b2b31aaff121f4918e3ee30a Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Thu, 15 Sep 2022 15:45:49 +0100 Subject: [PATCH 23/37] Update [contractSlug].tsx --- web/pages/embed/[username]/[contractSlug].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index c5fba0c8..fbeef88f 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -116,7 +116,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { tradingAllowed(contract) && !betPanelOpen && ( <Button color="gradient" onClick={() => setBetPanelOpen(true)}> - Bet + Predict </Button> )} From 718218c717c1093f7e4706e4bd35badcf171ec65 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Thu, 15 Sep 2022 15:51:14 +0100 Subject: [PATCH 24/37] Update bet-inline.tsx --- web/components/bet-inline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/bet-inline.tsx b/web/components/bet-inline.tsx index af75ff7c..a8f4d718 100644 --- a/web/components/bet-inline.tsx +++ b/web/components/bet-inline.tsx @@ -79,7 +79,7 @@ export function BetInline(props: { return ( <Col className={clsx('items-center', className)}> <Row className="h-12 items-stretch gap-3 rounded bg-indigo-200 py-2 px-3"> - <div className="text-xl">Bet</div> + <div className="text-xl">Predict</div> <YesNoSelector className="space-x-0" btnClassName="rounded-l-none rounded-r-none first:rounded-l-2xl last:rounded-r-2xl" From 4c10c8499b51c4e8a253a7230e8961f2dd03ed96 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 09:12:44 -0600 Subject: [PATCH 25/37] Take back unique bettor bonuses on N/A --- functions/src/resolve-market.ts | 60 ++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 44293898..b99b5c87 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -9,7 +9,7 @@ import { RESOLUTIONS, } from '../../common/contract' import { Bet } from '../../common/bet' -import { getUser, isProd, payUser } from './utils' +import { getUser, getValues, isProd, log, payUser } from './utils' import { getLoanPayouts, getPayouts, @@ -22,6 +22,12 @@ import { LiquidityProvision } from '../../common/liquidity-provision' import { APIError, newEndpoint, validate } from './api' import { getContractBetMetrics } from '../../common/calculate' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' +import { CancelUniqueBettorBonusTxn, Txn } from '../../common/txn' +import { runTxn, TxnData } from './transact' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' const bodySchema = z.object({ contractId: z.string(), @@ -163,6 +169,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { await processPayouts(liquidityPayouts, true) await processPayouts([...payouts, ...loanPayouts]) + await undoUniqueBettorRewardsIfCancelResolution(contract, outcome) const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) @@ -299,4 +306,55 @@ function validateAnswer( } } +async function undoUniqueBettorRewardsIfCancelResolution( + contract: Contract, + outcome: string +) { + if (outcome === 'CANCEL') { + const creatorsBonusTxns = await getValues<Txn>( + firestore + .collection('txns') + .where('category', '==', 'UNIQUE_BETTOR_BONUS') + .where('toId', '==', contract.creatorId) + ) + + const bonusTxnsOnThisContract = creatorsBonusTxns.filter( + (txn) => txn.data && txn.data.contractId === contract.id + ) + log('total bonusTxnsOnThisContract', bonusTxnsOnThisContract.length) + const totalBonusAmount = sumBy(bonusTxnsOnThisContract, (txn) => txn.amount) + log('totalBonusAmount to be withdrawn', totalBonusAmount) + const result = await firestore.runTransaction(async (trans) => { + const bonusTxn: TxnData = { + fromId: contract.creatorId, + fromType: 'USER', + toId: isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + toType: 'BANK', + amount: totalBonusAmount, + token: 'M$', + category: 'CANCEL_UNIQUE_BETTOR_BONUS', + data: { + contractId: contract.id, + }, + } as Omit<CancelUniqueBettorBonusTxn, 'id' | 'createdTime'> + return await runTxn(trans, bonusTxn) + }) + + if (result.status != 'success' || !result.txn) { + log( + `Couldn't cancel bonus for user: ${contract.creatorId} - status:`, + result.status + ) + log('message:', result.message) + } else { + log( + `Cancel Bonus txn for user: ${contract.creatorId} completed:`, + result.txn?.id + ) + } + } +} + const firestore = admin.firestore() From e9f136a653b6fc6a17a76ebbdad3d9a4e0ea4fb0 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 09:12:56 -0600 Subject: [PATCH 26/37] Single source of truth for predict --- common/envs/prod.ts | 6 ++++++ common/txn.ts | 12 ++++++++++++ common/user.ts | 8 ++++++++ common/util/format.ts | 4 ++++ web/components/bet-button.tsx | 5 +++-- web/components/contract-search.tsx | 6 +++--- web/components/contract/contract-info-dialog.tsx | 3 ++- web/components/contract/contract-leaderboard.tsx | 3 ++- web/components/contract/contract-tabs.tsx | 6 +++--- web/components/feed/feed-bets.tsx | 3 ++- web/components/feed/feed-comments.tsx | 4 ++-- web/components/feed/feed-liquidity.tsx | 4 ++-- web/components/liquidity-panel.tsx | 5 ++++- web/components/nav/nav-bar.tsx | 3 ++- web/components/nav/profile-menu.tsx | 3 ++- web/components/numeric-resolution-panel.tsx | 8 ++++++-- web/components/profile/loans-modal.tsx | 3 ++- web/components/resolution-panel.tsx | 16 +++++++++++----- web/components/user-page.tsx | 5 +++-- web/pages/contract-search-firestore.tsx | 3 ++- web/pages/group/[...slugs]/index.tsx | 3 ++- web/pages/leaderboards.tsx | 5 +++-- web/pages/stats.tsx | 3 ++- 23 files changed, 88 insertions(+), 33 deletions(-) diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 6bf781b7..a9d1ffc3 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -15,6 +15,9 @@ export type EnvConfig = { // Branding moneyMoniker: string // e.g. 'M$' + bettor?: string // e.g. 'bettor' or 'predictor' + presentBet?: string // e.g. 'bet' or 'predict' + pastBet?: string // e.g. 'bet' or 'prediction' faviconPath?: string // Should be a file in /public navbarLogoPath?: string newQuestionPlaceholders: string[] @@ -79,6 +82,9 @@ export const PROD_CONFIG: EnvConfig = { visibility: 'PUBLIC', moneyMoniker: 'M$', + bettor: 'predictor', + pastBet: 'prediction', + presentBet: 'predict', navbarLogoPath: '', faviconPath: '/favicon.ico', newQuestionPlaceholders: [ diff --git a/common/txn.ts b/common/txn.ts index ac3b76de..9c83761f 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -7,6 +7,7 @@ type AnyTxnType = | Referral | UniqueBettorBonus | BettingStreakBonus + | CancelUniqueBettorBonus type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn<T extends AnyTxnType = AnyTxnType> = { @@ -29,6 +30,7 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = { | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' + | 'CANCEL_UNIQUE_BETTOR_BONUS' // Any extra data data?: { [key: string]: any } @@ -89,9 +91,19 @@ type BettingStreakBonus = { } } +type CancelUniqueBettorBonus = { + fromType: 'USER' + toType: 'BANK' + category: 'CANCEL_UNIQUE_BETTOR_BONUS' + data: { + contractId: string + } +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink export type ReferralTxn = Txn & Referral export type BettingStreakBonusTxn = Txn & BettingStreakBonus export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus +export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus diff --git a/common/user.ts b/common/user.ts index 16a2b437..b490ab0c 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,4 +1,5 @@ import { notification_preferences } from './user-notification-preferences' +import { ENV_CONFIG } from 'common/envs/constants' export type User = { id: string @@ -83,3 +84,10 @@ export type PortfolioMetrics = { export const MANIFOLD_USERNAME = 'ManifoldMarkets' export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' + +export const BETTOR = ENV_CONFIG.bettor ?? 'bettor' // aka predictor +export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'bettors' +export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'bet' // aka predict +export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'bets' +export const PAST_BET = ENV_CONFIG.pastBet ?? 'bet' // aka prediction +export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'bets' // aka predictions diff --git a/common/util/format.ts b/common/util/format.ts index 4f123535..9b9ee1df 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -16,6 +16,10 @@ export function formatMoneyWithDecimals(amount: number) { return ENV_CONFIG.moneyMoniker + amount.toFixed(2) } +export function capitalFirst(s: string) { + return s.charAt(0).toUpperCase() + s.slice(1) +} + export function formatWithCommas(amount: number) { return formatter.format(Math.floor(amount)).replace('$', '') } diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 0bd3702f..c0177fb3 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -10,6 +10,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares' import { Col } from './layout/col' import { Button } from 'web/components/button' import { BetSignUpPrompt } from './sign-up-prompt' +import { PRESENT_BET } from 'common/user' /** Button that opens BetPanel in a new modal */ export default function BetButton(props: { @@ -36,12 +37,12 @@ export default function BetButton(props: { <Button size="lg" className={clsx( - 'my-auto inline-flex min-w-[75px] whitespace-nowrap', + 'my-auto inline-flex min-w-[75px] whitespace-nowrap capitalize', btnClassName )} onClick={() => setOpen(true)} > - Predict + {PRESENT_BET} </Button> ) : ( <BetSignUpPrompt /> diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 7f64b26b..a5e86545 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -3,7 +3,7 @@ import algoliasearch from 'algoliasearch/lite' import { SearchOptions } from '@algolia/client-search' import { useRouter } from 'next/router' import { Contract } from 'common/contract' -import { User } from 'common/user' +import { PAST_BETS, User } from 'common/user' import { ContractHighlightOptions, ContractsGrid, @@ -41,7 +41,7 @@ const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex' export const SORTS = [ { label: 'Newest', value: 'newest' }, { label: 'Trending', value: 'score' }, - { label: 'Most traded', value: 'most-traded' }, + { label: `Most ${PAST_BETS}`, value: 'most-traded' }, { label: '24h volume', value: '24-hour-vol' }, { label: '24h change', value: 'prob-change-day' }, { label: 'Last updated', value: 'last-updated' }, @@ -450,7 +450,7 @@ function ContractSearchControls(props: { selected={state.pillFilter === 'your-bets'} onSelect={selectPill('your-bets')} > - Your trades + Your {PAST_BETS} </PillButton> )} diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index ae586725..9027d38a 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -18,6 +18,7 @@ import { deleteField } from 'firebase/firestore' import ShortToggle from '../widgets/short-toggle' import { DuplicateContractButton } from '../copy-contract-button' import { Row } from '../layout/row' +import { BETTORS } from 'common/user' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -135,7 +136,7 @@ export function ContractInfoDialog(props: { </tr> */} <tr> - <td>Traders</td> + <td>{BETTORS}</td> <td>{bettorsCount}</td> </tr> diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index 54b2c79e..fec6744d 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -12,6 +12,7 @@ import { FeedComment } from '../feed/feed-comments' import { Spacer } from '../layout/spacer' import { Leaderboard } from '../leaderboard' import { Title } from '../title' +import { BETTORS } from 'common/user' export function ContractLeaderboard(props: { contract: Contract @@ -48,7 +49,7 @@ export function ContractLeaderboard(props: { return users && users.length > 0 ? ( <Leaderboard - title="🏅 Top traders" + title={`🏅 Top ${BETTORS}`} users={users || []} columns={[ { diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index d63d3963..5b88e005 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -1,7 +1,7 @@ import { Bet } from 'common/bet' import { Contract, CPMMBinaryContract } from 'common/contract' import { ContractComment } from 'common/comment' -import { User } from 'common/user' +import { PAST_BETS, User } from 'common/user' import { ContractCommentsActivity, ContractBetsActivity, @@ -114,13 +114,13 @@ export function ContractTabs(props: { badge: `${comments.length}`, }, { - title: 'Trades', + title: PAST_BETS, content: betActivity, badge: `${visibleBets.length}`, }, ...(!user || !userBets?.length ? [] - : [{ title: 'Your trades', content: yourTrades }]), + : [{ title: `Your ${PAST_BETS}`, content: yourTrades }]), ]} /> {!user ? ( diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index def97801..b2852739 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -14,6 +14,7 @@ import { SiteLink } from 'web/components/site-link' import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' import { Challenge } from 'common/challenge' import { UserLink } from 'web/components/user-link' +import { BETTOR } from 'common/user' export function FeedBet(props: { contract: Contract; bet: Bet }) { const { contract, bet } = props @@ -94,7 +95,7 @@ export function BetStatusText(props: { {!hideUser ? ( <UserLink name={bet.userName} username={bet.userUsername} /> ) : ( - <span>{self?.id === bet.userId ? 'You' : 'A trader'}</span> + <span>{self?.id === bet.userId ? 'You' : `A ${BETTOR}`}</span> )}{' '} {bought} {money} {outOfTotalAmount} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index f896ddb5..9d2ba85e 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -1,6 +1,6 @@ import { Bet } from 'common/bet' import { ContractComment } from 'common/comment' -import { User } from 'common/user' +import { PRESENT_BET, User } from 'common/user' import { Contract } from 'common/contract' import React, { useEffect, useState } from 'react' import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash' @@ -255,7 +255,7 @@ function CommentStatus(props: { const { contract, outcome, prob } = props return ( <> - {' betting '} + {` ${PRESENT_BET}ing `} <OutcomeLabel outcome={outcome} contract={contract} truncate="short" /> {prob && ' at ' + Math.round(prob * 100) + '%'} </> diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx index 8f8faf9b..181eb4b7 100644 --- a/web/components/feed/feed-liquidity.tsx +++ b/web/components/feed/feed-liquidity.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx' import dayjs from 'dayjs' -import { User } from 'common/user' +import { BETTOR, User } from 'common/user' import { useUser, useUserById } from 'web/hooks/use-user' import { Row } from 'web/components/layout/row' import { Avatar, EmptyAvatar } from 'web/components/avatar' @@ -74,7 +74,7 @@ export function LiquidityStatusText(props: { {bettor ? ( <UserLink name={bettor.name} username={bettor.username} /> ) : ( - <span>{isSelf ? 'You' : 'A trader'}</span> + <span>{isSelf ? 'You' : `A ${BETTOR}`}</span> )}{' '} {bought} a subsidy of {money} <RelativeTimestamp time={createdTime} /> diff --git a/web/components/liquidity-panel.tsx b/web/components/liquidity-panel.tsx index 0474abf7..58f57a8a 100644 --- a/web/components/liquidity-panel.tsx +++ b/web/components/liquidity-panel.tsx @@ -13,6 +13,7 @@ import { NoLabel, YesLabel } from './outcome-label' import { Col } from './layout/col' import { track } from 'web/lib/service/analytics' import { InfoTooltip } from './info-tooltip' +import { BETTORS, PRESENT_BET } from 'common/user' export function LiquidityPanel(props: { contract: CPMMContract }) { const { contract } = props @@ -104,7 +105,9 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) { <> <div className="mb-4 text-gray-500"> Contribute your M$ to make this market more accurate.{' '} - <InfoTooltip text="More liquidity stabilizes the market, encouraging traders to bet. You can withdraw your subsidy at any time." /> + <InfoTooltip + text={`More liquidity stabilizes the market, encouraging ${BETTORS} to ${PRESENT_BET}. You can withdraw your subsidy at any time.`} + /> </div> <Row> diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 242d6ff5..a07fa0ad 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -17,6 +17,7 @@ import { useRouter } from 'next/router' import NotificationsIcon from 'web/components/notifications-icon' import { useIsIframe } from 'web/hooks/use-is-iframe' import { trackCallback } from 'web/lib/service/analytics' +import { PAST_BETS } from 'common/user' function getNavigation() { return [ @@ -64,7 +65,7 @@ export function BottomNavBar() { item={{ name: formatMoney(user.balance), trackingEventName: 'profile', - href: `/${user.username}?tab=trades`, + href: `/${user.username}?tab=${PAST_BETS}`, icon: () => ( <Avatar className="mx-auto my-1" diff --git a/web/components/nav/profile-menu.tsx b/web/components/nav/profile-menu.tsx index e7cc056f..cf91ac66 100644 --- a/web/components/nav/profile-menu.tsx +++ b/web/components/nav/profile-menu.tsx @@ -4,11 +4,12 @@ import { User } from 'web/lib/firebase/users' import { formatMoney } from 'common/util/format' import { Avatar } from '../avatar' import { trackCallback } from 'web/lib/service/analytics' +import { PAST_BETS } from 'common/user' export function ProfileSummary(props: { user: User }) { const { user } = props return ( - <Link href={`/${user.username}?tab=trades`}> + <Link href={`/${user.username}?tab=${PAST_BETS}`}> <a onClick={trackCallback('sidebar: profile')} className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700" diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index 70fbf01f..0220f7a7 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -10,6 +10,7 @@ import { NumericContract, PseudoNumericContract } from 'common/contract' import { APIError, resolveMarket } from 'web/lib/firebase/api' import { BucketInput } from './bucket-input' import { getPseudoProbability } from 'common/pseudo-numeric' +import { BETTOR, BETTORS, PAST_BETS } from 'common/user' export function NumericResolutionPanel(props: { isAdmin: boolean @@ -111,9 +112,12 @@ export function NumericResolutionPanel(props: { <div> {outcome === 'CANCEL' ? ( - <>All trades will be returned with no fees.</> + <> + All {PAST_BETS} will be returned. Unique {BETTOR} bonuses will be + withdrawn from your account + </> ) : ( - <>Resolving this market will immediately pay out traders.</> + <>Resolving this market will immediately pay out {BETTORS}.</> )} </div> diff --git a/web/components/profile/loans-modal.tsx b/web/components/profile/loans-modal.tsx index 24b23e5b..5dcb8b6b 100644 --- a/web/components/profile/loans-modal.tsx +++ b/web/components/profile/loans-modal.tsx @@ -1,5 +1,6 @@ import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' +import { PAST_BETS } from 'common/user' export function LoansModal(props: { isOpen: boolean @@ -11,7 +12,7 @@ export function LoansModal(props: { <Modal open={isOpen} setOpen={setOpen}> <Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> <span className={'text-8xl'}>🏦</span> - <span className="text-xl">Daily loans on your trades</span> + <span className="text-xl">Daily loans on your {PAST_BETS}</span> <Col className={'gap-2'}> <span className={'text-indigo-700'}>• What are daily loans?</span> <span className={'ml-2'}> diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 6f36331e..7ef6e4f3 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -10,6 +10,7 @@ import { APIError, resolveMarket } from 'web/lib/firebase/api' import { ProbabilitySelector } from './probability-selector' import { getProbability } from 'common/calculate' import { BinaryContract, resolution } from 'common/contract' +import { BETTOR, BETTORS, PAST_BETS } from 'common/user' export function ResolutionPanel(props: { isAdmin: boolean @@ -90,23 +91,28 @@ export function ResolutionPanel(props: { <div> {outcome === 'YES' ? ( <> - Winnings will be paid out to traders who bought YES. + Winnings will be paid out to {BETTORS} who bought YES. {/* <br /> <br /> You will earn {earnedFees}. */} </> ) : outcome === 'NO' ? ( <> - Winnings will be paid out to traders who bought NO. + Winnings will be paid out to {BETTORS} who bought NO. {/* <br /> <br /> You will earn {earnedFees}. */} </> ) : outcome === 'CANCEL' ? ( - <>All trades will be returned with no fees.</> + <> + All {PAST_BETS} will be returned. Unique {BETTOR} bonuses will be + withdrawn from your account + </> ) : outcome === 'MKT' ? ( <Col className="gap-6"> - <div>Traders will be paid out at the probability you specify:</div> + <div> + {PAST_BETS} will be paid out at the probability you specify: + </div> <ProbabilitySelector probabilityInt={Math.round(prob)} setProbabilityInt={setProb} @@ -114,7 +120,7 @@ export function ResolutionPanel(props: { {/* You will earn {earnedFees}. */} </Col> ) : ( - <>Resolving this market will immediately pay out traders.</> + <>Resolving this market will immediately pay out {BETTORS}.</> )} </div> diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 5485267c..9dfd3491 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -25,7 +25,7 @@ import { UserFollowButton } from './follow-button' import { GroupsButton } from 'web/components/groups/groups-button' import { PortfolioValueSection } from './portfolio/portfolio-value-section' import { ReferralsButton } from 'web/components/referrals-button' -import { formatMoney } from 'common/util/format' +import { capitalFirst, formatMoney } from 'common/util/format' import { ShareIconButton } from 'web/components/share-icon-button' import { ENV_CONFIG } from 'common/envs/constants' import { @@ -35,6 +35,7 @@ import { import { REFERRAL_AMOUNT } from 'common/economy' import { LoansModal } from './profile/loans-modal' import { UserLikesButton } from 'web/components/profile/user-likes-button' +import { PAST_BETS } from 'common/user' export function UserPage(props: { user: User }) { const { user } = props @@ -269,7 +270,7 @@ export function UserPage(props: { user: User }) { ), }, { - title: 'Trades', + title: capitalFirst(PAST_BETS), content: ( <> <BetsList user={user} /> diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 4691030c..4d6ada1d 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -8,6 +8,7 @@ import { usePersistentState, urlParamStore, } from 'web/hooks/use-persistent-state' +import { PAST_BETS } from 'common/user' const MAX_CONTRACTS_RENDERED = 100 @@ -101,7 +102,7 @@ export default function ContractSearchFirestore(props: { > <option value="score">Trending</option> <option value="newest">Newest</option> - <option value="most-traded">Most traded</option> + <option value="most-traded">Most ${PAST_BETS}</option> <option value="24-hour-vol">24h volume</option> <option value="close-date">Closing soon</option> </select> diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index f1521b42..70b06ac5 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -50,6 +50,7 @@ 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' +import { BETTORS } from 'common/user' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -155,7 +156,7 @@ export default function GroupPage(props: { <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> <GroupLeaderboard topUsers={topTraders} - title="🏅 Top traders" + title={`🏅 Top ${BETTORS}`} header="Profit" maxToShow={maxLeaderboardSize} /> diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 08819833..4f1e9437 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -14,6 +14,7 @@ import { Title } from 'web/components/title' import { Tabs } from 'web/components/layout/tabs' import { useTracking } from 'web/hooks/use-tracking' import { SEO } from 'web/components/SEO' +import { BETTORS } from 'common/user' export async function getStaticProps() { const props = await fetchProps() @@ -79,7 +80,7 @@ export default function Leaderboards(_props: { <> <Col className="mx-4 items-center gap-10 lg:flex-row"> <Leaderboard - title="🏅 Top traders" + title={`🏅 Top ${BETTORS}`} users={topTraders} columns={[ { @@ -126,7 +127,7 @@ export default function Leaderboards(_props: { <Page> <SEO title="Leaderboards" - description="Manifold's leaderboards show the top traders and market creators." + description={`Manifold's leaderboards show the top ${BETTORS} and market creators.`} url="/leaderboards" /> <Title text={'Leaderboards'} className={'hidden md:block'} /> diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index bca0525a..057d47ef 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -13,6 +13,7 @@ import { SiteLink } from 'web/components/site-link' import { Linkify } from 'web/components/linkify' import { getStats } from 'web/lib/firebase/stats' import { Stats } from 'common/stats' +import { PAST_BETS } from 'common/user' export default function Analytics() { const [stats, setStats] = useState<Stats | undefined>(undefined) @@ -156,7 +157,7 @@ export function CustomAnalytics(props: { defaultIndex={0} tabs={[ { - title: 'Trades', + title: PAST_BETS, content: ( <DailyCountChart dailyCounts={dailyBetCounts} From be91d5d5e025c343270485a968f992dc3ac2cafa Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 09:51:52 -0600 Subject: [PATCH 27/37] Avatars don't link during contract selection --- common/txn.ts | 4 +--- web/components/contract-search.tsx | 7 ++++--- web/components/contract-select-modal.tsx | 6 +++++- web/components/contract/contract-card.tsx | 10 +++++++++- web/components/contract/contract-details.tsx | 11 +++++++++-- web/components/contract/contracts-grid.tsx | 8 +++++--- web/components/user-link.tsx | 9 +++++++-- 7 files changed, 40 insertions(+), 15 deletions(-) diff --git a/common/txn.ts b/common/txn.ts index 9c83761f..2b7a32e8 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -72,11 +72,10 @@ type UniqueBettorBonus = { fromType: 'BANK' toType: 'USER' category: 'UNIQUE_BETTOR_BONUS' - // This data was mistakenly stored as a stringified JSON object in description previously data: { contractId: string uniqueNewBettorId?: string - // Previously stored all unique bettor ids in description + // Old unique bettor bonus txns stored all unique bettor ids uniqueBettorIds?: string[] } } @@ -85,7 +84,6 @@ type BettingStreakBonus = { fromType: 'BANK' toType: 'USER' category: 'BETTING_STREAK_BONUS' - // This data was mistakenly stored as a stringified JSON object in description previously data: { currentBettingStreak?: number } diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index a5e86545..6044178e 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -80,9 +80,10 @@ export function ContractSearch(props: { highlightOptions?: ContractHighlightOptions onContractClick?: (contract: Contract) => void hideOrderSelector?: boolean - cardHideOptions?: { + cardUIOptions?: { hideGroupLink?: boolean hideQuickBet?: boolean + noLinkAvatar?: boolean } headerClassName?: string persistPrefix?: string @@ -102,7 +103,7 @@ export function ContractSearch(props: { additionalFilter, onContractClick, hideOrderSelector, - cardHideOptions, + cardUIOptions, highlightOptions, headerClassName, persistPrefix, @@ -223,7 +224,7 @@ export function ContractSearch(props: { showTime={state.showTime ?? undefined} onContractClick={onContractClick} highlightOptions={highlightOptions} - cardHideOptions={cardHideOptions} + cardUIOptions={cardUIOptions} /> )} </Col> diff --git a/web/components/contract-select-modal.tsx b/web/components/contract-select-modal.tsx index 9e23264a..2e534172 100644 --- a/web/components/contract-select-modal.tsx +++ b/web/components/contract-select-modal.tsx @@ -85,7 +85,11 @@ export function SelectMarketsModal(props: { <ContractSearch hideOrderSelector onContractClick={addContract} - cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} + cardUIOptions={{ + hideGroupLink: true, + hideQuickBet: true, + noLinkAvatar: true, + }} highlightOptions={{ contractIds: contracts.map((c) => c.id), highlightClassName: diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index dab92a7a..367a5401 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -42,6 +42,7 @@ export function ContractCard(props: { hideQuickBet?: boolean hideGroupLink?: boolean trackingPostfix?: string + noLinkAvatar?: boolean }) { const { showTime, @@ -51,6 +52,7 @@ export function ContractCard(props: { hideQuickBet, hideGroupLink, trackingPostfix, + noLinkAvatar, } = props const contract = useContractWithPreload(props.contract) ?? props.contract const { question, outcomeType } = contract @@ -78,6 +80,7 @@ export function ContractCard(props: { <AvatarDetails contract={contract} className={'hidden md:inline-flex'} + noLink={noLinkAvatar} /> <p className={clsx( @@ -142,7 +145,12 @@ export function ContractCard(props: { showQuickBet ? 'w-[85%]' : 'w-full' )} > - <AvatarDetails contract={contract} short={true} className="md:hidden" /> + <AvatarDetails + contract={contract} + short={true} + className="md:hidden" + noLink={noLinkAvatar} + /> <MiscDetails contract={contract} showTime={showTime} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index e28ab41a..0a65d4d9 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -86,8 +86,9 @@ export function AvatarDetails(props: { contract: Contract className?: string short?: boolean + noLink?: boolean }) { - const { contract, short, className } = props + const { contract, short, className, noLink } = props const { creatorName, creatorUsername, creatorAvatarUrl } = contract return ( @@ -98,8 +99,14 @@ export function AvatarDetails(props: { username={creatorUsername} avatarUrl={creatorAvatarUrl} size={6} + noLink={noLink} + /> + <UserLink + name={creatorName} + username={creatorUsername} + short={short} + noLink={noLink} /> - <UserLink name={creatorName} username={creatorUsername} short={short} /> </Row> ) } diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index c6356fdd..fcf20f02 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -21,9 +21,10 @@ export function ContractsGrid(props: { loadMore?: () => void showTime?: ShowTime onContractClick?: (contract: Contract) => void - cardHideOptions?: { + cardUIOptions?: { hideQuickBet?: boolean hideGroupLink?: boolean + noLinkAvatar?: boolean } highlightOptions?: ContractHighlightOptions trackingPostfix?: string @@ -34,11 +35,11 @@ export function ContractsGrid(props: { showTime, loadMore, onContractClick, - cardHideOptions, + cardUIOptions, highlightOptions, trackingPostfix, } = props - const { hideQuickBet, hideGroupLink } = cardHideOptions || {} + const { hideQuickBet, hideGroupLink, noLinkAvatar } = cardUIOptions || {} const { contractIds, highlightClassName } = highlightOptions || {} const onVisibilityUpdated = useCallback( (visible) => { @@ -80,6 +81,7 @@ export function ContractsGrid(props: { onClick={ onContractClick ? () => onContractClick(contract) : undefined } + noLinkAvatar={noLinkAvatar} hideQuickBet={hideQuickBet} hideGroupLink={hideGroupLink} trackingPostfix={trackingPostfix} diff --git a/web/components/user-link.tsx b/web/components/user-link.tsx index e1b675a0..4b05ccd0 100644 --- a/web/components/user-link.tsx +++ b/web/components/user-link.tsx @@ -20,13 +20,18 @@ export function UserLink(props: { username: string className?: string short?: boolean + noLink?: boolean }) { - const { name, username, className, short } = props + const { name, username, className, short, noLink } = props const shortName = short ? shortenName(name) : name return ( <SiteLink href={`/${username}`} - className={clsx('z-10 truncate', className)} + className={clsx( + 'z-10 truncate', + className, + noLink ? 'pointer-events-none' : '' + )} > {shortName} </SiteLink> From b3e6dce31ef08bd9c51c6c1687d95d9844a6fb8b Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 09:57:14 -0600 Subject: [PATCH 28/37] Capitalize --- common/util/format.ts | 4 ---- web/components/contract/contract-tabs.tsx | 3 ++- web/components/user-page.tsx | 5 +++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/common/util/format.ts b/common/util/format.ts index 9b9ee1df..4f123535 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -16,10 +16,6 @@ export function formatMoneyWithDecimals(amount: number) { return ENV_CONFIG.moneyMoniker + amount.toFixed(2) } -export function capitalFirst(s: string) { - return s.charAt(0).toUpperCase() + s.slice(1) -} - export function formatWithCommas(amount: number) { return formatter.format(Math.floor(amount)).replace('$', '') } diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 5b88e005..0796dcb2 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -18,6 +18,7 @@ import { useLiquidity } from 'web/hooks/use-liquidity' import { BetSignUpPrompt } from '../sign-up-prompt' import { PlayMoneyDisclaimer } from '../play-money-disclaimer' import BetButton from '../bet-button' +import { capitalize } from 'lodash' export function ContractTabs(props: { contract: Contract @@ -114,7 +115,7 @@ export function ContractTabs(props: { badge: `${comments.length}`, }, { - title: PAST_BETS, + title: capitalize(PAST_BETS), content: betActivity, badge: `${visibleBets.length}`, }, diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 9dfd3491..6d7f0b2c 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -25,7 +25,7 @@ import { UserFollowButton } from './follow-button' import { GroupsButton } from 'web/components/groups/groups-button' import { PortfolioValueSection } from './portfolio/portfolio-value-section' import { ReferralsButton } from 'web/components/referrals-button' -import { capitalFirst, formatMoney } from 'common/util/format' +import { formatMoney } from 'common/util/format' import { ShareIconButton } from 'web/components/share-icon-button' import { ENV_CONFIG } from 'common/envs/constants' import { @@ -36,6 +36,7 @@ import { REFERRAL_AMOUNT } from 'common/economy' import { LoansModal } from './profile/loans-modal' import { UserLikesButton } from 'web/components/profile/user-likes-button' import { PAST_BETS } from 'common/user' +import { capitalize } from 'lodash' export function UserPage(props: { user: User }) { const { user } = props @@ -270,7 +271,7 @@ export function UserPage(props: { user: User }) { ), }, { - title: capitalFirst(PAST_BETS), + title: capitalize(PAST_BETS), content: ( <> <BetsList user={user} /> From 69c2570ff97d71d48f463604acaa44757f673e6d Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 15 Sep 2022 12:29:57 -0700 Subject: [PATCH 29/37] fix copy to make clear referrals aren't limited --- web/components/user-page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 6d7f0b2c..8dc7928a 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -242,7 +242,8 @@ export function UserPage(props: { user: User }) { <SiteLink href="/referrals"> Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend! </SiteLink>{' '} - You have <ReferralsButton user={user} currentUser={currentUser} /> + You've gotten + <ReferralsButton user={user} currentUser={currentUser} /> </span> <ShareIconButton copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`} From 8c6a40bab7300e5c6da459da5f8091643e807e70 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 13:39:46 -0600 Subject: [PATCH 30/37] Enrich limit order notification --- common/notification.ts | 17 +- functions/src/create-notification.ts | 7 + .../scripts/backfill-contract-followers.ts | 8 +- web/pages/notifications.tsx | 233 +++++++++++++----- 4 files changed, 190 insertions(+), 75 deletions(-) diff --git a/common/notification.ts b/common/notification.ts index c34f5b9c..2f03467d 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -92,11 +92,6 @@ export type notification_reason_types = | 'your_contract_closed' | 'subsidized_your_market' -export type BettingStreakData = { - streak: number - bonusAmount: number -} - type notification_descriptions = { [key in notification_preference]: { simple: string @@ -241,3 +236,15 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { detailed: `Answers on markets that you're watching and that you're invested in`, }, } + +export type BettingStreakData = { + streak: number + bonusAmount: number +} + +export type BetFillData = { + betOutcome: string + creatorOutcome: string + probability: number + fillAmount: number +} diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index ba9fa5c4..390a8cd8 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -1,5 +1,6 @@ import * as admin from 'firebase-admin' import { + BetFillData, BettingStreakData, Notification, notification_reason_types, @@ -542,6 +543,12 @@ export const createBetFillNotification = async ( sourceContractTitle: contract.question, sourceContractSlug: contract.slug, sourceContractId: contract.id, + data: { + betOutcome: bet.outcome, + creatorOutcome: userBet.outcome, + fillAmount, + probability: userBet.limitProb, + } as BetFillData, } return await notificationRef.set(removeUndefinedProps(notification)) diff --git a/functions/src/scripts/backfill-contract-followers.ts b/functions/src/scripts/backfill-contract-followers.ts index 9b936654..9b5834bc 100644 --- a/functions/src/scripts/backfill-contract-followers.ts +++ b/functions/src/scripts/backfill-contract-followers.ts @@ -4,14 +4,14 @@ import { initAdmin } from './script-init' initAdmin() import { getValues } from '../utils' -import { Contract } from 'common/lib/contract' -import { Comment } from 'common/lib/comment' +import { Contract } from 'common/contract' +import { Comment } from 'common/comment' import { uniq } from 'lodash' -import { Bet } from 'common/lib/bet' +import { Bet } from 'common/bet' import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, -} from 'common/lib/antes' +} from 'common/antes' const firestore = admin.firestore() diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 008f5df1..bc5e8cc6 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,7 +1,11 @@ import { ControlledTabs } from 'web/components/layout/tabs' import React, { useEffect, useMemo, useState } from 'react' import Router, { useRouter } from 'next/router' -import { Notification, notification_source_types } from 'common/notification' +import { + BetFillData, + Notification, + notification_source_types, +} from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' @@ -141,6 +145,7 @@ function RenderNotificationGroups(props: { <NotificationItem notification={notification.notifications[0]} key={notification.notifications[0].id} + justSummary={false} /> ) : ( <NotificationGroupItem @@ -697,20 +702,11 @@ function NotificationGroupItem(props: { function NotificationItem(props: { notification: Notification - justSummary?: boolean + justSummary: boolean isChildOfGroup?: boolean }) { const { notification, justSummary, isChildOfGroup } = props - const { - sourceType, - sourceUserName, - sourceUserAvatarUrl, - sourceUpdateType, - reasonText, - reason, - sourceUserUsername, - sourceText, - } = notification + const { sourceType, reason } = notification const [highlighted] = useState(!notification.isSeen) @@ -718,39 +714,103 @@ function NotificationItem(props: { setNotificationsAsSeen([notification]) }, [notification]) - const questionNeedsResolution = sourceUpdateType == 'closed' + // TODO Any new notification should be its own component + if (reason === 'bet_fill') { + return ( + <BetFillNotification + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + justSummary={justSummary} + /> + ) + } + // TODO Add new notification components here if (justSummary) { return ( - <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> - <div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}> - <div className={'flex pl-1 sm:pl-0'}> - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-0 flex-shrink-0'} - short={true} - /> - <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> - <span className={'flex-shrink-0'}> - {sourceType && - reason && - getReasonForShowingNotification(notification, true)} - </span> - <div className={'ml-1 text-black'}> - <NotificationTextLabel - className={'line-clamp-1'} - notification={notification} - justSummary={true} - /> - </div> - </div> - </div> - </div> - </Row> + <NotificationSummaryFrame + notification={notification} + subtitle={ + (sourceType && + reason && + getReasonForShowingNotification(notification, true)) ?? + '' + } + > + <NotificationTextLabel + className={'line-clamp-1'} + notification={notification} + justSummary={true} + /> + </NotificationSummaryFrame> ) } + return ( + <NotificationFrame + notification={notification} + subtitle={getReasonForShowingNotification( + notification, + isChildOfGroup ?? false + )} + highlighted={highlighted} + > + <div className={'mt-1 ml-1 md:text-base'}> + <NotificationTextLabel notification={notification} /> + </div> + </NotificationFrame> + ) +} + +function NotificationSummaryFrame(props: { + notification: Notification + subtitle: string + children: React.ReactNode +}) { + const { notification, subtitle, children } = props + const { sourceUserName, sourceUserUsername } = notification + return ( + <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> + <div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}> + <div className={'flex pl-1 sm:pl-0'}> + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-0 flex-shrink-0'} + short={true} + /> + <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> + <span className={'flex-shrink-0'}>{subtitle}</span> + <div className={'line-clamp-1 ml-1 text-black'}>{children}</div> + </div> + </div> + </div> + </Row> + ) +} + +function NotificationFrame(props: { + notification: Notification + highlighted: boolean + subtitle: string + children: React.ReactNode + isChildOfGroup?: boolean +}) { + const { notification, isChildOfGroup, highlighted, subtitle, children } = + props + const { + sourceType, + sourceUserName, + sourceUserAvatarUrl, + sourceUpdateType, + reason, + reasonText, + sourceUserUsername, + sourceText, + } = notification + const questionNeedsResolution = sourceUpdateType == 'closed' + return ( <div className={clsx( @@ -796,18 +856,13 @@ function NotificationItem(props: { } > <div> - {!questionNeedsResolution && ( - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'relative mr-1 flex-shrink-0'} - short={true} - /> - )} - {getReasonForShowingNotification( - notification, - isChildOfGroup ?? false - )} + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'relative mr-1 flex-shrink-0'} + short={true} + /> + {subtitle} {isChildOfGroup ? ( <RelativeTimestamp time={notification.createdTime} /> ) : ( @@ -822,9 +877,7 @@ function NotificationItem(props: { )} </div> </Row> - <div className={'mt-1 ml-1 md:text-base'}> - <NotificationTextLabel notification={notification} /> - </div> + <div className={'mt-1 ml-1 md:text-base'}>{children}</div> <div className={'mt-6 border-b border-gray-300'} /> </div> @@ -832,6 +885,66 @@ function NotificationItem(props: { ) } +function BetFillNotification(props: { + notification: Notification + highlighted: boolean + justSummary: boolean + isChildOfGroup?: boolean +}) { + const { notification, isChildOfGroup, highlighted, justSummary } = props + const { sourceText, data } = notification + const { creatorOutcome, probability } = (data as BetFillData) ?? {} + const subtitle = 'bet against you' + const amount = formatMoney(parseInt(sourceText ?? '0')) + const description = + creatorOutcome && probability ? ( + <span> + of your{' '} + <span + className={ + creatorOutcome === 'YES' + ? 'text-primary' + : creatorOutcome === 'NO' + ? 'text-red-500' + : 'text-blue-500' + } + > + {creatorOutcome}{' '} + </span> + limit order at {Math.round(probability * 100)}% was filled + </span> + ) : ( + <span>of your limit order was filled</span> + ) + + if (justSummary) { + return ( + <NotificationSummaryFrame notification={notification} subtitle={subtitle}> + <Row className={'line-clamp-1'}> + <span className={'text-primary mr-1'}>{amount}</span> + <span>{description}</span> + </Row> + </NotificationSummaryFrame> + ) + } + + return ( + <NotificationFrame + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + subtitle={subtitle} + > + <Row> + <span> + <span className="text-primary mr-1">{amount}</span> + {description} + </span> + </Row> + </NotificationFrame> + ) +} + export const setNotificationsAsSeen = async (notifications: Notification[]) => { const unseenNotifications = notifications.filter((n) => !n.isSeen) return await Promise.all( @@ -1002,15 +1115,6 @@ function NotificationTextLabel(props: { return ( <span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span> ) - } else if (sourceType === 'bet' && sourceText) { - return ( - <> - <span className="text-primary"> - {formatMoney(parseInt(sourceText))} - </span>{' '} - <span>of your limit order was filled</span> - </> - ) } else if (sourceType === 'challenge' && sourceText) { return ( <> @@ -1074,9 +1178,6 @@ function getReasonForShowingNotification( else if (sourceSlug) reasonText = 'joined because you shared' else reasonText = 'joined because of you' break - case 'bet': - reasonText = 'bet against you' - break case 'challenge': reasonText = 'accepted your challenge' break From 1476f669d3b08f55632336570fdcadc068068c55 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 15 Sep 2022 13:45:49 -0700 Subject: [PATCH 31/37] Fix capitalization --- web/pages/stats.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index 057d47ef..08fb5498 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -14,6 +14,7 @@ import { Linkify } from 'web/components/linkify' import { getStats } from 'web/lib/firebase/stats' import { Stats } from 'common/stats' import { PAST_BETS } from 'common/user' +import { capitalize } from 'lodash' export default function Analytics() { const [stats, setStats] = useState<Stats | undefined>(undefined) @@ -157,7 +158,7 @@ export function CustomAnalytics(props: { defaultIndex={0} tabs={[ { - title: PAST_BETS, + title: capitalize(PAST_BETS), content: ( <DailyCountChart dailyCounts={dailyBetCounts} From b903183fff9f8c00a0f372ccb2ca05141250a184 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 15 Sep 2022 13:47:07 -0700 Subject: [PATCH 32/37] Paginate contract bets tab (#881) * Apply pagination to bets list on contract * Make contract trades tab number actually match number of entries --- web/components/contract/contract-tabs.tsx | 16 ++++++++-- web/components/feed/contract-activity.tsx | 39 ++++++++++++++++------- web/components/feed/feed-liquidity.tsx | 13 +------- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 0796dcb2..e4b95d97 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -19,6 +19,10 @@ import { BetSignUpPrompt } from '../sign-up-prompt' import { PlayMoneyDisclaimer } from '../play-money-disclaimer' import BetButton from '../bet-button' import { capitalize } from 'lodash' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from 'common/antes' export function ContractTabs(props: { contract: Contract @@ -37,13 +41,19 @@ export function ContractTabs(props: { const visibleBets = bets.filter( (bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0 ) - const visibleLps = lps?.filter((l) => !l.isAnte && l.amount > 0) + const visibleLps = (lps ?? []).filter( + (l) => + !l.isAnte && + l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID && + l.userId !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID && + l.amount > 0 + ) // Load comments here, so the badge count will be correct const updatedComments = useComments(contract.id) const comments = updatedComments ?? props.comments - const betActivity = visibleLps && ( + const betActivity = lps != null && ( <ContractBetsActivity contract={contract} bets={visibleBets} @@ -117,7 +127,7 @@ export function ContractTabs(props: { { title: capitalize(PAST_BETS), content: betActivity, - badge: `${visibleBets.length}`, + badge: `${visibleBets.length + visibleLps.length}`, }, ...(!user || !userBets?.length ? [] diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index 55b8a958..b8a003fa 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -1,8 +1,10 @@ +import { useState } from 'react' import { Contract, FreeResponseContract } from 'common/contract' import { ContractComment } from 'common/comment' import { Answer } from 'common/answer' import { Bet } from 'common/bet' import { getOutcomeProbability } from 'common/calculate' +import { Pagination } from 'web/components/pagination' import { FeedBet } from './feed-bets' import { FeedLiquidity } from './feed-liquidity' import { FeedAnswerCommentGroup } from './feed-answer-comment-group' @@ -19,6 +21,10 @@ export function ContractBetsActivity(props: { lps: LiquidityProvision[] }) { const { contract, bets, lps } = props + const [page, setPage] = useState(0) + const ITEMS_PER_PAGE = 50 + const start = page * ITEMS_PER_PAGE + const end = start + ITEMS_PER_PAGE const items = [ ...bets.map((bet) => ({ @@ -33,24 +39,35 @@ export function ContractBetsActivity(props: { })), ] - const sortedItems = sortBy(items, (item) => + const pageItems = sortBy(items, (item) => item.type === 'bet' ? -item.bet.createdTime : item.type === 'liquidity' ? -item.lp.createdTime : undefined - ) + ).slice(start, end) return ( - <Col className="gap-4"> - {sortedItems.map((item) => - item.type === 'bet' ? ( - <FeedBet key={item.id} contract={contract} bet={item.bet} /> - ) : ( - <FeedLiquidity key={item.id} liquidity={item.lp} /> - ) - )} - </Col> + <> + <Col className="mb-4 gap-4"> + {pageItems.map((item) => + item.type === 'bet' ? ( + <FeedBet key={item.id} contract={contract} bet={item.bet} /> + ) : ( + <FeedLiquidity key={item.id} liquidity={item.lp} /> + ) + )} + </Col> + <Pagination + page={page} + itemsPerPage={50} + totalItems={items.length} + setPage={setPage} + scrollToTop + nextTitle={'Older'} + prevTitle={'Newer'} + /> + </> ) } diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx index 181eb4b7..f4870a4e 100644 --- a/web/components/feed/feed-liquidity.tsx +++ b/web/components/feed/feed-liquidity.tsx @@ -9,17 +9,13 @@ import { RelativeTimestamp } from 'web/components/relative-timestamp' import React from 'react' import { LiquidityProvision } from 'common/liquidity-provision' import { UserLink } from 'web/components/user-link' -import { - DEV_HOUSE_LIQUIDITY_PROVIDER_ID, - HOUSE_LIQUIDITY_PROVIDER_ID, -} from 'common/antes' export function FeedLiquidity(props: { className?: string liquidity: LiquidityProvision }) { const { liquidity } = props - const { userId, createdTime, isAnte } = liquidity + const { userId, createdTime } = liquidity const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01') // eslint-disable-next-line react-hooks/rules-of-hooks @@ -28,13 +24,6 @@ export function FeedLiquidity(props: { const user = useUser() const isSelf = user?.id === userId - if ( - isAnte || - userId === HOUSE_LIQUIDITY_PROVIDER_ID || - userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID - ) - return <></> - return ( <Row className="items-center gap-2 pt-3"> {isSelf ? ( From 7628713c4b43a99992b9184be6346d0b21df8b39 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 15:25:19 -0600 Subject: [PATCH 33/37] Enrich contract resolved notification --- common/follow.ts | 5 + common/notification.ts | 6 + common/user-notification-preferences.ts | 1 + functions/src/create-notification.ts | 168 +++++++++++++++++++----- functions/src/resolve-market.ts | 32 +---- functions/src/utils.ts | 2 +- web/pages/notifications.tsx | 117 +++++++++++++---- 7 files changed, 243 insertions(+), 88 deletions(-) diff --git a/common/follow.ts b/common/follow.ts index 04ca6899..7ff6e7f2 100644 --- a/common/follow.ts +++ b/common/follow.ts @@ -2,3 +2,8 @@ export type Follow = { userId: string timestamp: number } + +export type ContractFollow = { + id: string // user id + createdTime: number +} diff --git a/common/notification.ts b/common/notification.ts index 2f03467d..804ec68e 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -248,3 +248,9 @@ export type BetFillData = { probability: number fillAmount: number } + +export type ContractResolutionData = { + outcome: string + userPayout: number + userInvestment: number +} diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts index e2402ea9..f585f373 100644 --- a/common/user-notification-preferences.ts +++ b/common/user-notification-preferences.ts @@ -218,6 +218,7 @@ const notificationReasonToSubscriptionType: Partial< export const getNotificationDestinationsForUser = ( privateUser: PrivateUser, + // TODO: accept reasons array from most to least important and work backwards reason: notification_reason_types | notification_preference ) => { const notificationSettings = privateUser.notificationPreferences diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 390a8cd8..ebd3f26c 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -2,6 +2,7 @@ import * as admin from 'firebase-admin' import { BetFillData, BettingStreakData, + ContractResolutionData, Notification, notification_reason_types, } from '../../common/notification' @@ -28,6 +29,7 @@ import { } from './emails' import { filterDefined } from '../../common/util/array' import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' +import { ContractFollow } from '../../common/follow' const firestore = admin.firestore() type recipients_to_reason_texts = { @@ -159,7 +161,7 @@ export type replied_users_info = { export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceId: string, sourceType: 'comment' | 'answer' | 'contract', - sourceUpdateType: 'created' | 'updated' | 'resolved', + sourceUpdateType: 'created' | 'updated', sourceUser: User, idempotencyKey: string, sourceText: string, @@ -167,17 +169,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( miscData?: { repliedUsersInfo: replied_users_info taggedUserIds: string[] - }, - resolutionData?: { - bets: Bet[] - userInvestments: { [userId: string]: number } - userPayouts: { [userId: string]: number } - creator: User - creatorPayout: number - contract: Contract - outcome: string - resolutionProbability?: number - resolutions?: { [outcome: string]: number } } ) => { const { repliedUsersInfo, taggedUserIds } = miscData ?? {} @@ -230,11 +221,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( userId: string, reason: notification_reason_types ) => { - if ( - !stillFollowingContract(sourceContract.creatorId) || - sourceUser.id == userId - ) - return + if (!stillFollowingContract(userId) || sourceUser.id == userId) return const privateUser = await getPrivateUser(userId) if (!privateUser) return const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( @@ -276,24 +263,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceUser.avatarUrl ) emailRecipientIdsList.push(userId) - } else if ( - sourceType === 'contract' && - sourceUpdateType === 'resolved' && - resolutionData - ) { - await sendMarketResolutionEmail( - reason, - privateUser, - resolutionData.userInvestments[userId] ?? 0, - resolutionData.userPayouts[userId] ?? 0, - sourceUser, - resolutionData.creatorPayout, - sourceContract, - resolutionData.outcome, - resolutionData.resolutionProbability, - resolutionData.resolutions - ) - emailRecipientIdsList.push(userId) } } @@ -447,6 +416,8 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( ) } + //TODO: store all possible reasons why the user might be getting the notification and choose the most lenient that they + // have enabled so they will unsubscribe from the least important notifications await notifyRepliedUser() await notifyTaggedUsers() await notifyContractCreator() @@ -943,3 +914,130 @@ export const createNewContractNotification = async ( await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user') } } + +export const createContractResolvedNotifications = async ( + contract: Contract, + creator: User, + outcome: string, + probabilityInt: number | undefined, + resolutionValue: number | undefined, + resolutionData: { + bets: Bet[] + userInvestments: { [userId: string]: number } + userPayouts: { [userId: string]: number } + creator: User + creatorPayout: number + contract: Contract + outcome: string + resolutionProbability?: number + resolutions?: { [outcome: string]: number } + } +) => { + let resolutionText = outcome ?? contract.question + if ( + contract.outcomeType === 'FREE_RESPONSE' || + contract.outcomeType === 'MULTIPLE_CHOICE' + ) { + const answerText = contract.answers.find( + (answer) => answer.id === outcome + )?.text + if (answerText) resolutionText = answerText + } else if (contract.outcomeType === 'BINARY') { + if (resolutionText === 'MKT' && probabilityInt) + resolutionText = `${probabilityInt}%` + else if (resolutionText === 'MKT') resolutionText = 'PROB' + } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { + if (resolutionText === 'MKT' && resolutionValue) + resolutionText = `${resolutionValue}` + } + + const idempotencyKey = contract.id + '-resolved' + const createBrowserNotification = async ( + userId: string, + reason: notification_reason_types + ) => { + const notificationRef = firestore + .collection(`/users/${userId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId, + reason, + createdTime: Date.now(), + isSeen: false, + sourceId: contract.id, + sourceType: 'contract', + sourceUpdateType: 'resolved', + sourceContractId: contract.id, + sourceUserName: creator.name, + sourceUserUsername: creator.username, + sourceUserAvatarUrl: creator.avatarUrl, + sourceText: resolutionText, + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + sourceSlug: contract.slug, + sourceTitle: contract.question, + data: { + outcome, + userInvestment: resolutionData.userInvestments[userId] ?? 0, + userPayout: resolutionData.userPayouts[userId] ?? 0, + } as ContractResolutionData, + } + return await notificationRef.set(removeUndefinedProps(notification)) + } + + const sendNotificationsIfSettingsPermit = async ( + userId: string, + reason: notification_reason_types + ) => { + if (!stillFollowingContract(userId) || creator.id == userId) return + const privateUser = await getPrivateUser(userId) + if (!privateUser) return + const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( + privateUser, + reason + ) + + // Browser notifications + if (sendToBrowser) { + await createBrowserNotification(userId, reason) + } + + // Emails notifications + if (sendToEmail) + await sendMarketResolutionEmail( + reason, + privateUser, + resolutionData.userInvestments[userId] ?? 0, + resolutionData.userPayouts[userId] ?? 0, + creator, + resolutionData.creatorPayout, + contract, + resolutionData.outcome, + resolutionData.resolutionProbability, + resolutionData.resolutions + ) + } + + const contractFollowersIds = ( + await getValues<ContractFollow>( + firestore.collection(`contracts/${contract.id}/follows`) + ) + ).map((follow) => follow.id) + + const stillFollowingContract = (userId: string) => { + return contractFollowersIds.includes(userId) + } + + await Promise.all( + contractFollowersIds.map((id) => + sendNotificationsIfSettingsPermit( + id, + resolutionData.userInvestments[id] + ? 'resolution_on_contract_with_users_shares_in' + : 'resolution_on_contract_you_follow' + ) + ) + ) +} diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index b99b5c87..feddd67c 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -21,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 { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' +import { createContractResolvedNotifications } from './create-notification' import { CancelUniqueBettorBonusTxn, Txn } from '../../common/txn' import { runTxn, TxnData } from './transact' import { @@ -177,33 +177,13 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { groupBy(bets, (bet) => bet.userId), (bets) => getContractBetMetrics(contract, bets).invested ) - let resolutionText = outcome ?? contract.question - if ( - contract.outcomeType === 'FREE_RESPONSE' || - contract.outcomeType === 'MULTIPLE_CHOICE' - ) { - const answerText = contract.answers.find( - (answer) => answer.id === outcome - )?.text - if (answerText) resolutionText = answerText - } else if (contract.outcomeType === 'BINARY') { - if (resolutionText === 'MKT' && probabilityInt) - resolutionText = `${probabilityInt}%` - else if (resolutionText === 'MKT') resolutionText = 'PROB' - } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { - if (resolutionText === 'MKT' && value) resolutionText = `${value}` - } - // TODO: this actually may be too slow to complete with a ton of users to notify? - await createCommentOrAnswerOrUpdatedContractNotification( - contract.id, - 'contract', - 'resolved', - creator, - contract.id + '-resolution', - resolutionText, + await createContractResolvedNotifications( contract, - undefined, + creator, + outcome, + probabilityInt, + value, { bets, userInvestments, diff --git a/functions/src/utils.ts b/functions/src/utils.ts index a0878e4f..23f7257a 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -4,7 +4,7 @@ import { chunk } from 'lodash' import { Contract } from '../../common/contract' import { PrivateUser, User } from '../../common/user' import { Group } from '../../common/group' -import { Post } from 'common/post' +import { Post } from '../../common/post' export const log = (...args: unknown[]) => { console.log(`[${new Date().toISOString()}]`, ...args) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index bc5e8cc6..14f14ea4 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useMemo, useState } from 'react' import Router, { useRouter } from 'next/router' import { BetFillData, + ContractResolutionData, Notification, notification_source_types, } from 'common/notification' @@ -706,7 +707,7 @@ function NotificationItem(props: { isChildOfGroup?: boolean }) { const { notification, justSummary, isChildOfGroup } = props - const { sourceType, reason } = notification + const { sourceType, reason, sourceUpdateType } = notification const [highlighted] = useState(!notification.isSeen) @@ -724,6 +725,15 @@ function NotificationItem(props: { justSummary={justSummary} /> ) + } else if (sourceType === 'contract' && sourceUpdateType === 'resolved') { + return ( + <ContractResolvedNotification + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + justSummary={justSummary} + /> + ) } // TODO Add new notification components here @@ -810,7 +820,8 @@ function NotificationFrame(props: { sourceText, } = notification const questionNeedsResolution = sourceUpdateType == 'closed' - + const { width } = useWindowSize() + const isMobile = (width ?? 0) < 600 return ( <div className={clsx( @@ -860,7 +871,7 @@ function NotificationFrame(props: { name={sourceUserName || ''} username={sourceUserUsername || ''} className={'relative mr-1 flex-shrink-0'} - short={true} + short={isMobile} /> {subtitle} {isChildOfGroup ? ( @@ -945,6 +956,83 @@ function BetFillNotification(props: { ) } +function ContractResolvedNotification(props: { + notification: Notification + highlighted: boolean + justSummary: boolean + isChildOfGroup?: boolean +}) { + const { notification, isChildOfGroup, highlighted, justSummary } = props + const { sourceText, data } = notification + const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {} + const subtitle = 'resolved the market' + const resolutionDescription = () => { + if (!sourceText) return <div /> + if (sourceText === 'YES' || sourceText == 'NO') { + return <BinaryOutcomeLabel outcome={sourceText as any} /> + } + if (sourceText.includes('%')) + return <ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} /> + if (sourceText === 'CANCEL') return <CancelLabel /> + if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel /> + + // Numeric market + if (parseFloat(sourceText)) + return <NumericValueLabel value={parseFloat(sourceText)} /> + + // Free response market + return ( + <div className={'line-clamp-1 text-blue-400'}> + <Linkify text={sourceText} /> + </div> + ) + } + + const description = + userInvestment && userPayout ? ( + <Row className={'gap-1 '}> + {resolutionDescription()} + Invested: + <span className={'text-primary'}>{formatMoney(userInvestment)} </span> + Payout: + <span + className={clsx( + userPayout > 0 ? 'text-primary' : 'text-red-500', + 'truncate' + )} + > + {formatMoney(userPayout)} + {` (${userPayout > 0 ? '+' : '-'}${Math.round( + ((userPayout - userInvestment) / userInvestment) * 100 + )}%)`} + </span> + </Row> + ) : ( + <span>{resolutionDescription()}</span> + ) + + if (justSummary) { + return ( + <NotificationSummaryFrame notification={notification} subtitle={subtitle}> + <Row className={'line-clamp-1'}>{description}</Row> + </NotificationSummaryFrame> + ) + } + + return ( + <NotificationFrame + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + subtitle={subtitle} + > + <Row> + <span>{description}</span> + </Row> + </NotificationFrame> + ) +} + export const setNotificationsAsSeen = async (notifications: Notification[]) => { const unseenNotifications = notifications.filter((n) => !n.isSeen) return await Promise.all( @@ -1064,30 +1152,7 @@ function NotificationTextLabel(props: { if (sourceType === 'contract') { if (justSummary || !sourceText) return <div /> // Resolved contracts - if (sourceType === 'contract' && sourceUpdateType === 'resolved') { - { - if (sourceText === 'YES' || sourceText == 'NO') { - return <BinaryOutcomeLabel outcome={sourceText as any} /> - } - if (sourceText.includes('%')) - return ( - <ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} /> - ) - if (sourceText === 'CANCEL') return <CancelLabel /> - if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel /> - // Numeric market - if (parseFloat(sourceText)) - return <NumericValueLabel value={parseFloat(sourceText)} /> - - // Free response market - return ( - <div className={className ? className : 'line-clamp-1 text-blue-400'}> - <Linkify text={sourceText} /> - </div> - ) - } - } // Close date will be a number - it looks better without it if (sourceUpdateType === 'closed') { return <div /> From 61c672ce4c959f26451158219086b98faccc2f39 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 15:50:26 -0600 Subject: [PATCH 34/37] Show negative payouts --- web/pages/notifications.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 14f14ea4..a0c1ede5 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -989,7 +989,7 @@ function ContractResolvedNotification(props: { } const description = - userInvestment && userPayout ? ( + userInvestment && userPayout !== undefined ? ( <Row className={'gap-1 '}> {resolutionDescription()} Invested: @@ -1002,7 +1002,7 @@ function ContractResolvedNotification(props: { )} > {formatMoney(userPayout)} - {` (${userPayout > 0 ? '+' : '-'}${Math.round( + {` (${userPayout > 0 ? '+' : ''}${Math.round( ((userPayout - userInvestment) / userInvestment) * 100 )}%)`} </span> From 3362b2f953639a74621ae2d06c19d4f51dea6de1 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 15:51:39 -0600 Subject: [PATCH 35/37] Capitalize --- web/components/contract/contract-info-dialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 9027d38a..76a48277 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -19,6 +19,7 @@ import ShortToggle from '../widgets/short-toggle' import { DuplicateContractButton } from '../copy-contract-button' import { Row } from '../layout/row' import { BETTORS } from 'common/user' +import { capitalize } from 'lodash' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -136,7 +137,7 @@ export function ContractInfoDialog(props: { </tr> */} <tr> - <td>{BETTORS}</td> + <td>{capitalize(BETTORS)}</td> <td>{bettorsCount}</td> </tr> From e9fcf5a352626b293c281fa49275154c4918b7fb Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 15 Sep 2022 16:12:05 -0600 Subject: [PATCH 36/37] Space --- web/components/user-page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 8dc7928a..2b24fa60 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -242,7 +242,7 @@ export function UserPage(props: { user: User }) { <SiteLink href="/referrals"> Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend! </SiteLink>{' '} - You've gotten + You've gotten{' '} <ReferralsButton user={user} currentUser={currentUser} /> </span> <ShareIconButton From 140628692f9e09b6f2e582b31f88974dac581524 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 15 Sep 2022 15:12:26 -0700 Subject: [PATCH 37/37] Use %mention to embed a contract card in rich text editor (#869) * Bring up a list of contracts with @ * Fix hot reload for RichContent * Render contracts as half-size cards * Use % as the prompt; allow for spaces * WIP: When there's no matching question, create a new contract * Revert "WIP: When there's no matching question, create a new contract" This reverts commit efae1bf715dfe02b88169d181a22d6f0fe7ad480. * Rename to contract-mention * WIP: Try to merge in @ and % side by side * Add a different pluginKey * Track the prosemirror-state dep --- web/components/editor.tsx | 19 ++++- .../editor/contract-mention-list.tsx | 68 +++++++++++++++++ .../editor/contract-mention-suggestion.ts | 76 +++++++++++++++++++ web/components/editor/contract-mention.tsx | 41 ++++++++++ web/hooks/use-contracts.ts | 9 ++- web/package.json | 1 + 6 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 web/components/editor/contract-mention-list.tsx create mode 100644 web/components/editor/contract-mention-suggestion.ts create mode 100644 web/components/editor/contract-mention.tsx diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 745fc3c5..95f18b3f 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -21,6 +21,8 @@ import { FileUploadButton } from './file-upload-button' import { linkClass } from './site-link' import { mentionSuggestion } from './editor/mention-suggestion' import { DisplayMention } from './editor/mention' +import { contractMentionSuggestion } from './editor/contract-mention-suggestion' +import { DisplayContractMention } from './editor/contract-mention' import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' import { EmbedModal } from './editor/embed-modal' @@ -97,7 +99,12 @@ export function useTextEditor(props: { CharacterCount.configure({ limit: max }), simple ? DisplayImage : Image, DisplayLink, - DisplayMention.configure({ suggestion: mentionSuggestion }), + DisplayMention.configure({ + suggestion: mentionSuggestion, + }), + DisplayContractMention.configure({ + suggestion: contractMentionSuggestion, + }), Iframe, TiptapTweet, ], @@ -316,13 +323,21 @@ export function RichContent(props: { smallImage ? DisplayImage : Image, DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) DisplayMention, + DisplayContractMention.configure({ + // Needed to set a different PluginKey for Prosemirror + suggestion: contractMentionSuggestion, + }), Iframe, TiptapTweet, ], content, editable: false, }) - useEffect(() => void editor?.commands?.setContent(content), [editor, content]) + useEffect( + // Check isDestroyed here so hot reload works, see https://github.com/ueberdosis/tiptap/issues/1451#issuecomment-941988769 + () => void !editor?.isDestroyed && editor?.commands?.setContent(content), + [editor, content] + ) return <EditorContent className={className} editor={editor} /> } diff --git a/web/components/editor/contract-mention-list.tsx b/web/components/editor/contract-mention-list.tsx new file mode 100644 index 00000000..bda9d2fc --- /dev/null +++ b/web/components/editor/contract-mention-list.tsx @@ -0,0 +1,68 @@ +import { SuggestionProps } from '@tiptap/suggestion' +import clsx from 'clsx' +import { Contract } from 'common/contract' +import { forwardRef, useEffect, useImperativeHandle, useState } from 'react' +import { contractPath } from 'web/lib/firebase/contracts' +import { Avatar } from '../avatar' + +// copied from https://tiptap.dev/api/nodes/mention#usage +const M = forwardRef((props: SuggestionProps<Contract>, ref) => { + const { items: contracts, command } = props + + const [selectedIndex, setSelectedIndex] = useState(0) + useEffect(() => setSelectedIndex(0), [contracts]) + + const submitUser = (index: number) => { + const contract = contracts[index] + if (contract) + command({ id: contract.id, label: contractPath(contract) } as any) + } + + const onUp = () => + setSelectedIndex((i) => (i + contracts.length - 1) % contracts.length) + const onDown = () => setSelectedIndex((i) => (i + 1) % contracts.length) + const onEnter = () => submitUser(selectedIndex) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: any) => { + if (event.key === 'ArrowUp') { + onUp() + return true + } + if (event.key === 'ArrowDown') { + onDown() + return true + } + if (event.key === 'Enter') { + onEnter() + return true + } + return false + }, + })) + + return ( + <div className="w-42 absolute z-10 overflow-x-hidden rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> + {!contracts.length ? ( + <span className="m-1 whitespace-nowrap">No results...</span> + ) : ( + contracts.map((contract, i) => ( + <button + className={clsx( + 'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4 hover:bg-indigo-200', + selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900' + )} + onClick={() => submitUser(i)} + key={contract.id} + > + <Avatar avatarUrl={contract.creatorAvatarUrl} size="xs" /> + {contract.question} + </button> + )) + )} + </div> + ) +}) + +// Just to keep the formatting pretty +export { M as MentionList } diff --git a/web/components/editor/contract-mention-suggestion.ts b/web/components/editor/contract-mention-suggestion.ts new file mode 100644 index 00000000..79525cfc --- /dev/null +++ b/web/components/editor/contract-mention-suggestion.ts @@ -0,0 +1,76 @@ +import type { MentionOptions } from '@tiptap/extension-mention' +import { ReactRenderer } from '@tiptap/react' +import { searchInAny } from 'common/util/parse' +import { orderBy } from 'lodash' +import tippy from 'tippy.js' +import { getCachedContracts } from 'web/hooks/use-contracts' +import { MentionList } from './contract-mention-list' +import { PluginKey } from 'prosemirror-state' + +type Suggestion = MentionOptions['suggestion'] + +const beginsWith = (text: string, query: string) => + text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase()) + +// copied from https://tiptap.dev/api/nodes/mention#usage +// TODO: merge with mention-suggestion.ts? +export const contractMentionSuggestion: Suggestion = { + char: '%', + allowSpaces: true, + pluginKey: new PluginKey('contract-mention'), + items: async ({ query }) => + orderBy( + (await getCachedContracts()).filter((c) => + searchInAny(query, c.question) + ), + [(c) => [c.question].some((s) => beginsWith(s, query))], + ['desc', 'desc'] + ).slice(0, 5), + render: () => { + let component: ReactRenderer + let popup: ReturnType<typeof tippy> + return { + onStart: (props) => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }) + if (!props.clientRect) { + return + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect as any, + appendTo: () => document.body, + content: component?.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }) + }, + onUpdate(props) { + component?.updateProps(props) + + if (!props.clientRect) { + return + } + + popup?.[0].setProps({ + getReferenceClientRect: props.clientRect as any, + }) + }, + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.[0].hide() + return true + } + return (component?.ref as any)?.onKeyDown(props) + }, + onExit() { + popup?.[0].destroy() + component?.destroy() + }, + } + }, +} diff --git a/web/components/editor/contract-mention.tsx b/web/components/editor/contract-mention.tsx new file mode 100644 index 00000000..9e967044 --- /dev/null +++ b/web/components/editor/contract-mention.tsx @@ -0,0 +1,41 @@ +import Mention from '@tiptap/extension-mention' +import { + mergeAttributes, + NodeViewWrapper, + ReactNodeViewRenderer, +} from '@tiptap/react' +import clsx from 'clsx' +import { useContract } from 'web/hooks/use-contract' +import { ContractCard } from '../contract/contract-card' + +const name = 'contract-mention-component' + +const ContractMentionComponent = (props: any) => { + const contract = useContract(props.node.attrs.id) + + return ( + <NodeViewWrapper className={clsx(name, 'not-prose')}> + {contract && ( + <ContractCard + contract={contract} + className="my-2 w-full border border-gray-100" + /> + )} + </NodeViewWrapper> + ) +} + +/** + * Mention extension that renders React. See: + * https://tiptap.dev/guide/custom-extensions#extend-existing-extensions + * https://tiptap.dev/guide/node-views/react#render-a-react-component + */ +export const DisplayContractMention = Mention.extend({ + parseHTML: () => [{ tag: name }], + renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)], + addNodeView: () => + ReactNodeViewRenderer(ContractMentionComponent, { + // On desktop, render cards below half-width so you can stack two + className: 'inline-block sm:w-[calc(50%-1rem)] sm:mr-1', + }), +}) diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 1ea2f232..87eefa38 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -9,8 +9,9 @@ import { listenForNewContracts, getUserBetContracts, getUserBetContractsQuery, + listAllContracts, } from 'web/lib/firebase/contracts' -import { useQueryClient } from 'react-query' +import { QueryClient, useQueryClient } from 'react-query' import { MINUTE_MS } from 'common/util/time' export const useContracts = () => { @@ -23,6 +24,12 @@ export const useContracts = () => { return contracts } +const q = new QueryClient() +export const getCachedContracts = async () => + q.fetchQuery(['contracts'], () => listAllContracts(1000), { + staleTime: Infinity, + }) + export const useActiveContracts = () => { const [activeContracts, setActiveContracts] = useState< Contract[] | undefined diff --git a/web/package.json b/web/package.json index 114ded1e..ba25a6e1 100644 --- a/web/package.json +++ b/web/package.json @@ -48,6 +48,7 @@ "nanoid": "^3.3.4", "next": "12.2.5", "node-fetch": "3.2.4", + "prosemirror-state": "1.4.1", "react": "17.0.2", "react-beautiful-dnd": "13.1.1", "react-confetti": "6.0.1",