From e17234ecce46dade2f7db68bf98b7848af7411ff Mon Sep 17 00:00:00 2001 From: mantikoros Date: Sat, 10 Sep 2022 17:43:52 -0500 Subject: [PATCH 01/76] typo --- functions/src/email-templates/creating-market.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html index a61e8d65..df215bdc 100644 --- a/functions/src/email-templates/creating-market.html +++ b/functions/src/email-templates/creating-market.html @@ -186,8 +186,9 @@ font-family: Readex Pro, Arial, Helvetica, sans-serif; font-size: 17px; - ">Did you know you create your own prediction market on Manifold for + ">Did you know you can create your own prediction market on Manifold on any question you care about?

From b39e0f304f7472e0e3e2ae2539b9c0f56d26b430 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Sat, 10 Sep 2022 17:07:23 -0600 Subject: [PATCH 02/76] Yes and no buttons on contract page (#868) * Yes and no buttons on contract page * Cheating by adding 0.05 to max shares but gives better quickbet UX --- web/components/bet-button.tsx | 23 ++-- web/components/contract/contract-card.tsx | 8 +- web/components/contract/contract-overview.tsx | 41 ++++-- .../{quick-bet.tsx => quick-bet-arrows.tsx} | 4 +- web/components/contract/quick-bet-button.tsx | 128 ++++++++++++++++++ 5 files changed, 179 insertions(+), 25 deletions(-) rename web/components/contract/{quick-bet.tsx => quick-bet-arrows.tsx} (98%) create mode 100644 web/components/contract/quick-bet-button.tsx diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 0bd3702f..2aadbc78 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -32,6 +32,17 @@ export default function BetButton(props: { return ( <> + {user && ( +
+ {hasYesShares + ? `(${Math.floor(yesShares)} ${ + isPseudoNumeric ? 'HIGHER' : 'YES' + })` + : hasNoShares + ? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})` + : ''} +
+ )} {user ? ( + ) +} + +// Return a number from 0 to 1 for this contract +// Resolved contracts are set to 1, for coloring purposes (even if NO) +function getProb(contract: Contract) { + const { outcomeType, resolution, resolutionProbability } = contract + return resolutionProbability + ? resolutionProbability + : resolution + ? 1 + : outcomeType === 'BINARY' + ? getBinaryProb(contract) + : outcomeType === 'PSEUDO_NUMERIC' + ? getProbability(contract) + : outcomeType === 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE' + ? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '') + : outcomeType === 'NUMERIC' + ? getNumericScale(contract) + : 1 // Should not happen +} + +function getNumericScale(contract: NumericContract) { + const { min, max } = contract + const ev = getExpectedValue(contract) + return (ev - min) / (max - min) +} From 9ee71733055bbf7ade9b340f98709fc6cd6151aa Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Sat, 10 Sep 2022 17:48:35 -0600 Subject: [PATCH 03/76] Put sale value above quick bet button --- web/components/bet-button.tsx | 17 +------ web/components/contract/contract-overview.tsx | 2 +- web/components/contract/quick-bet-button.tsx | 48 ++++++++++++++----- 3 files changed, 39 insertions(+), 28 deletions(-) diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 2aadbc78..75d12211 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -23,26 +23,11 @@ export default function BetButton(props: { const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) - const { yesShares, noShares, hasYesShares, hasNoShares } = - useSaveBinaryShares(contract, userBets) - - const { outcomeType } = contract - const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + const { hasYesShares, hasNoShares } = useSaveBinaryShares(contract, userBets) return ( <> - {user && ( -
- {hasYesShares - ? `(${Math.floor(yesShares)} ${ - isPseudoNumeric ? 'HIGHER' : 'YES' - })` - : hasNoShares - ? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})` - : ''} -
- )} {user ? ( + + {user && ( +
+ {saleValueOfShares > 0.1 && + hasYesShares && + side === 'YES' && + `${formatMoney(saleValueOfShares)}`} + {saleValueOfShares > 0.1 && + hasNoShares && + side === 'NO' && + `${formatMoney(saleValueOfShares)}`} +
+ )} + + ) } From 18815caed43f9cb25fa8a1cc92d96b040e0f5490 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Sat, 10 Sep 2022 21:57:35 -0500 Subject: [PATCH 04/76] Revert "Put sale value above quick bet button" This reverts commit 9ee71733055bbf7ade9b340f98709fc6cd6151aa. --- web/components/bet-button.tsx | 17 ++++++- web/components/contract/contract-overview.tsx | 2 +- web/components/contract/quick-bet-button.tsx | 48 +++++-------------- 3 files changed, 28 insertions(+), 39 deletions(-) diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 75d12211..2aadbc78 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -23,11 +23,26 @@ export default function BetButton(props: { const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) - const { hasYesShares, hasNoShares } = useSaveBinaryShares(contract, userBets) + const { yesShares, noShares, hasYesShares, hasNoShares } = + useSaveBinaryShares(contract, userBets) + + const { outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' return ( <> + {user && ( +
+ {hasYesShares + ? `(${Math.floor(yesShares)} ${ + isPseudoNumeric ? 'HIGHER' : 'YES' + })` + : hasNoShares + ? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})` + : ''} +
+ )} {user ? ( - + ) } From 93033b5b243ca115806f3ec755a167e9d29cab79 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Sat, 10 Sep 2022 21:57:50 -0500 Subject: [PATCH 05/76] Revert "Yes and no buttons on contract page (#868)" This reverts commit b39e0f304f7472e0e3e2ae2539b9c0f56d26b430. --- web/components/bet-button.tsx | 23 ++-- web/components/contract/contract-card.tsx | 8 +- web/components/contract/contract-overview.tsx | 41 ++---- web/components/contract/quick-bet-button.tsx | 128 ------------------ .../{quick-bet-arrows.tsx => quick-bet.tsx} | 4 +- 5 files changed, 25 insertions(+), 179 deletions(-) delete mode 100644 web/components/contract/quick-bet-button.tsx rename web/components/contract/{quick-bet-arrows.tsx => quick-bet.tsx} (98%) diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 2aadbc78..0bd3702f 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -32,17 +32,6 @@ export default function BetButton(props: { return ( <> - {user && ( -
- {hasYesShares - ? `(${Math.floor(yesShares)} ${ - isPseudoNumeric ? 'HIGHER' : 'YES' - })` - : hasNoShares - ? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})` - : ''} -
- )} {user ? ( - ) -} - -// Return a number from 0 to 1 for this contract -// Resolved contracts are set to 1, for coloring purposes (even if NO) -function getProb(contract: Contract) { - const { outcomeType, resolution, resolutionProbability } = contract - return resolutionProbability - ? resolutionProbability - : resolution - ? 1 - : outcomeType === 'BINARY' - ? getBinaryProb(contract) - : outcomeType === 'PSEUDO_NUMERIC' - ? getProbability(contract) - : outcomeType === 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE' - ? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '') - : outcomeType === 'NUMERIC' - ? getNumericScale(contract) - : 1 // Should not happen -} - -function getNumericScale(contract: NumericContract) { - const { min, max } = contract - const ev = getExpectedValue(contract) - return (ev - min) / (max - min) -} diff --git a/web/components/contract/quick-bet-arrows.tsx b/web/components/contract/quick-bet.tsx similarity index 98% rename from web/components/contract/quick-bet-arrows.tsx rename to web/components/contract/quick-bet.tsx index 678963bf..7b19306f 100644 --- a/web/components/contract/quick-bet-arrows.tsx +++ b/web/components/contract/quick-bet.tsx @@ -38,7 +38,7 @@ import { getBinaryProb } from 'common/contract-details' const BET_SIZE = 10 -export function QuickBetArrows(props: { +export function QuickBet(props: { contract: BinaryContract | PseudoNumericContract user: User className?: string @@ -243,7 +243,7 @@ export function ProbBar(props: { contract: Contract; previewProb?: number }) { ) } -export function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') { +function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') { const { outcomeType } = contract if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { From f8d346a404cf6656e1a1c0039a8460d085a1cfed Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Sun, 11 Sep 2022 11:42:05 -0700 Subject: [PATCH 06/76] Clean up charity styles - center cards on mobile - make notes more professional --- web/pages/charity/index.tsx | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 0bc6f0f8..2f4407d9 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -178,7 +178,7 @@ export default function Charity(props: { className="input input-bordered mb-6 w-full" /> -
+
{filterCharities.map((charity) => (
-
- Notes -
- - Don't see your favorite charity? Recommend it by emailing - charity@manifold.markets! -
- - Manifold is not affiliated with non-Featured charities; we're just - fans of their work. -
- - As Manifold itself is a for-profit entity, your contributions will - not be tax deductible. -
- Donations + matches are wired once each quarter. +
+ Notes +
    +
  • + Don't see your favorite charity? Recommend it by emailing{' '} + + charity@manifold.markets + + ! +
  • +
  • + Manifold is not affiliated with non-Featured charities; we're just + fans of their work. +
  • +
  • + As Manifold itself is a for-profit entity, your contributions will + not be tax deductible. +
  • +
  • Donations + matches are wired once each quarter.
  • +
From c1287a4a25afa7f9406929eca95420c72ba7c7c9 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 12 Sep 2022 00:39:04 -0500 Subject: [PATCH 07/76] Small updates to experimental home (#870) * Line clamp question in prob change table * Tweaks * Expand option for daily movers * Snap scrolling for carousel * Add arrows to section headers * Remove carousel from experimental/home * React querify fetching your groups * Edit home is its own page * Add daily profit and balance * Merge branch 'main' into new-home * Make experimental search by your followed groups/creators * Just submit, allow xs on pills * Weigh in * Use next/future/image component to optimize avatar images * Inga/challenge icon (#857) * changed challenge icon to custom icon * fixed tip button alignment * weighing in and trading "weigh in" for "trade" * Delete closing soon, mark new as New for you, trending is site-wide * Delete your trades. Factor out section item * Don't allow hiding of home sections * Convert daily movers into a section * Tweaks for loading daily movers * Prob change table shows variable number of rows * Fix double negative Co-authored-by: Ian Philips Co-authored-by: Austin Chen Co-authored-by: ingawei <46611122+ingawei@users.noreply.github.com> Co-authored-by: mantikoros --- common/user.ts | 2 +- web/components/arrange-home.tsx | 103 +++++++--------- web/components/contract/prob-change-table.tsx | 113 +++++++++--------- web/pages/experimental/home/edit.tsx | 11 +- web/pages/experimental/home/index.tsx | 68 +++++++---- 5 files changed, 146 insertions(+), 151 deletions(-) diff --git a/common/user.ts b/common/user.ts index 0e333278..f15865cf 100644 --- a/common/user.ts +++ b/common/user.ts @@ -34,7 +34,7 @@ export type User = { followerCountCached: number followedCategories?: string[] - homeSections?: { visible: string[]; hidden: string[] } + homeSections?: string[] referredByUserId?: string referredByContractId?: string diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index ae02e3ea..25e814b8 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -13,19 +13,13 @@ import { Group } from 'common/group' export function ArrangeHome(props: { user: User | null | undefined - homeSections: { visible: string[]; hidden: string[] } - setHomeSections: (homeSections: { - visible: string[] - hidden: string[] - }) => void + homeSections: string[] + setHomeSections: (sections: string[]) => void }) { const { user, homeSections, setHomeSections } = props const groups = useMemberGroups(user?.id) ?? [] - const { itemsById, visibleItems, hiddenItems } = getHomeItems( - groups, - homeSections - ) + const { itemsById, sections } = getHomeItems(groups, homeSections) return ( item.id), - hidden: hiddenItems.map((item) => item.id), - } + const newHomeSections = sections.map((section) => section.id) - const sourceSection = source.droppableId as 'visible' | 'hidden' - newHomeSections[sourceSection].splice(source.index, 1) - - const destSection = destination.droppableId as 'visible' | 'hidden' - newHomeSections[destSection].splice(destination.index, 0, item.id) + newHomeSections.splice(source.index, 1) + newHomeSections.splice(destination.index, 0, item.id) setHomeSections(newHomeSections) }} > - - - + + ) @@ -64,16 +51,13 @@ function DraggableList(props: { const { title, items } = props return ( - {(provided, snapshot) => ( + {(provided) => ( - + {items.map((item, index) => ( {(provided, snapshot) => ( @@ -82,16 +66,13 @@ function DraggableList(props: { {...provided.draggableProps} {...provided.dragHandleProps} style={provided.draggableProps.style} - className={clsx( - 'flex flex-row items-center gap-4 rounded bg-gray-50 p-2', - snapshot.isDragging && 'z-[9000] bg-gray-300' - )} > -
)} @@ -103,15 +84,33 @@ function DraggableList(props: { ) } -export const getHomeItems = ( - groups: Group[], - homeSections: { visible: string[]; hidden: string[] } -) => { +const SectionItem = (props: { + item: { id: string; label: string } + className?: string +}) => { + const { item, className } = props + + return ( +
+
+ ) +} + +export const getHomeItems = (groups: Group[], sections: string[]) => { const items = [ + { label: 'Daily movers', id: 'daily-movers' }, { label: 'Trending', id: 'score' }, - { label: 'Newest', id: 'newest' }, - { label: 'Close date', id: 'close-date' }, - { label: 'Your trades', id: 'your-bets' }, + { label: 'New for you', id: 'newest' }, ...groups.map((g) => ({ label: g.name, id: g.id, @@ -119,23 +118,13 @@ export const getHomeItems = ( ] const itemsById = keyBy(items, 'id') - const { visible, hidden } = homeSections + const sectionItems = filterDefined(sections.map((id) => itemsById[id])) - const [visibleItems, hiddenItems] = [ - filterDefined(visible.map((id) => itemsById[id])), - filterDefined(hidden.map((id) => itemsById[id])), - ] - - // Add unmentioned items to the visible list. - visibleItems.push( - ...items.filter( - (item) => !visibleItems.includes(item) && !hiddenItems.includes(item) - ) - ) + // Add unmentioned items to the end. + sectionItems.push(...items.filter((item) => !sectionItems.includes(item))) return { - visibleItems, - hiddenItems, + sections: sectionItems, itemsById, } } diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index f973d260..49216b88 100644 --- a/web/components/contract/prob-change-table.tsx +++ b/web/components/contract/prob-change-table.tsx @@ -2,74 +2,69 @@ import clsx from 'clsx' import { contractPath } from 'web/lib/firebase/contracts' import { CPMMContract } from 'common/contract' import { formatPercent } from 'common/util/format' -import { useProbChanges } from 'web/hooks/use-prob-changes' -import { linkClass, SiteLink } from '../site-link' +import { SiteLink } from '../site-link' import { Col } from '../layout/col' import { Row } from '../layout/row' -import { useState } from 'react' +import { LoadingIndicator } from '../loading-indicator' -export function ProbChangeTable(props: { userId: string | undefined }) { - const { userId } = props +export function ProbChangeTable(props: { + changes: + | { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] } + | undefined +}) { + const { changes } = props - const changes = useProbChanges(userId ?? '') - const [expanded, setExpanded] = useState(false) - - if (!changes) { - return null - } - - const count = expanded ? 16 : 4 + if (!changes) return const { positiveChanges, negativeChanges } = changes - const filteredPositiveChanges = positiveChanges.slice(0, count / 2) - const filteredNegativeChanges = negativeChanges.slice(0, count / 2) - const filteredChanges = [ - ...filteredPositiveChanges, - ...filteredNegativeChanges, - ] + + const threshold = 0.075 + const countOverThreshold = Math.max( + positiveChanges.findIndex((c) => c.probChanges.day < threshold) + 1, + negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1 + ) + const maxRows = Math.min(positiveChanges.length, negativeChanges.length) + const rows = Math.min(3, Math.min(maxRows, countOverThreshold)) + + const filteredPositiveChanges = positiveChanges.slice(0, rows) + const filteredNegativeChanges = negativeChanges.slice(0, rows) + + if (rows === 0) return
None
return ( - - - - {filteredChanges.slice(0, count / 2).map((contract) => ( - - - - {contract.question} - - - ))} - - - {filteredChanges.slice(count / 2).map((contract) => ( - - - - {contract.question} - - - ))} - + + + {filteredPositiveChanges.map((contract) => ( + + + + {contract.question} + + + ))} + + + {filteredNegativeChanges.map((contract) => ( + + + + {contract.question} + + + ))} -
setExpanded(!expanded)} - > - {expanded ? 'Show less' : 'Show more'} -
) } diff --git a/web/pages/experimental/home/edit.tsx b/web/pages/experimental/home/edit.tsx index 2cba3f19..2ed9d2dd 100644 --- a/web/pages/experimental/home/edit.tsx +++ b/web/pages/experimental/home/edit.tsx @@ -16,14 +16,9 @@ export default function Home() { useTracking('edit home') - const [homeSections, setHomeSections] = useState( - user?.homeSections ?? { visible: [], hidden: [] } - ) + const [homeSections, setHomeSections] = useState(user?.homeSections ?? []) - const updateHomeSections = (newHomeSections: { - visible: string[] - hidden: string[] - }) => { + const updateHomeSections = (newHomeSections: string[]) => { if (!user) return updateUser(user.id, { homeSections: newHomeSections }) setHomeSections(newHomeSections) @@ -31,7 +26,7 @@ export default function Home() { return ( - + <DoneButton /> diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 90b4f888..08f502b6 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React from 'react' import Router from 'next/router' import { PencilIcon, @@ -28,6 +28,7 @@ import { groupPath } from 'web/lib/firebase/groups' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { calculatePortfolioProfit } from 'common/calculate-metrics' import { formatMoney } from 'common/util/format' +import { useProbChanges } from 'web/hooks/use-prob-changes' const Home = () => { const user = useUser() @@ -38,10 +39,7 @@ const Home = () => { const groups = useMemberGroups(user?.id) ?? [] - const [homeSections] = useState( - user?.homeSections ?? { visible: [], hidden: [] } - ) - const { visibleItems } = getHomeItems(groups, homeSections) + const { sections } = getHomeItems(groups, user?.homeSections ?? []) return ( <Page> @@ -54,29 +52,19 @@ const Home = () => { <DailyProfitAndBalance userId={user?.id} /> - <div className="text-xl text-gray-800">Daily movers</div> - <ProbChangeTable userId={user?.id} /> - - {visibleItems.map((item) => { + {sections.map((item) => { const { id } = item - if (id === 'your-bets') { - return ( - <SearchSection - key={id} - label={'Your trades'} - sort={'newest'} - user={user} - yourBets - /> - ) + if (id === 'daily-movers') { + return <DailyMoversSection key={id} userId={user?.id} /> } const sort = SORTS.find((sort) => sort.value === id) if (sort) return ( <SearchSection key={id} - label={sort.label} + label={sort.value === 'newest' ? 'New for you' : sort.label} sort={sort.value} + followed={sort.value === 'newest'} user={user} /> ) @@ -103,11 +91,12 @@ const Home = () => { function SearchSection(props: { label: string - user: User | null | undefined + user: User | null | undefined | undefined sort: Sort yourBets?: boolean + followed?: boolean }) { - const { label, user, sort, yourBets } = props + const { label, user, sort, yourBets, followed } = props const href = `/home?s=${sort}` return ( @@ -122,7 +111,13 @@ function SearchSection(props: { <ContractSearch user={user} defaultSort={sort} - additionalFilter={yourBets ? { yourBets: true } : { followed: true }} + additionalFilter={ + yourBets + ? { yourBets: true } + : followed + ? { followed: true } + : undefined + } noControls maxResults={6} persistPrefix={`experimental-home-${sort}`} @@ -131,7 +126,10 @@ function SearchSection(props: { ) } -function GroupSection(props: { group: Group; user: User | null | undefined }) { +function GroupSection(props: { + group: Group + user: User | null | undefined | undefined +}) { const { group, user } = props return ( @@ -155,6 +153,24 @@ function GroupSection(props: { group: Group; user: User | null | undefined }) { ) } +function DailyMoversSection(props: { userId: string | null | undefined }) { + const { userId } = props + const changes = useProbChanges(userId ?? '') + + return ( + <Col className="gap-2"> + <SiteLink className="text-xl" href={'/daily-movers'}> + Daily movers{' '} + <ArrowSmRightIcon + className="mb-0.5 inline h-6 w-6 text-gray-500" + aria-hidden="true" + /> + </SiteLink> + <ProbChangeTable changes={changes} /> + </Col> + ) +} + function EditButton(props: { className?: string }) { const { className } = props @@ -186,14 +202,14 @@ function DailyProfitAndBalance(props: { return ( <div className={clsx(className, 'text-lg')}> <span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}> - {profit >= 0 ? '+' : '-'} + {profit >= 0 && '+'} {formatMoney(profit)} </span>{' '} profit and{' '} <span className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')} > - {balanceChange >= 0 ? '+' : '-'} + {balanceChange >= 0 && '+'} {formatMoney(balanceChange)} </span>{' '} balance today From a6ed8c92282dd26310ee6b7c029365065ec53fdd Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Mon, 12 Sep 2022 16:44:24 +0100 Subject: [PATCH 08/76] Fix "500 internal error" in large groups (#856) * Members to memberIds * Moved to update-metrics --- common/group.ts | 15 ++ functions/src/update-metrics.ts | 72 +++++++- web/lib/firebase/groups.ts | 5 +- web/pages/group/[...slugs]/index.tsx | 263 +++++++-------------------- 4 files changed, 145 insertions(+), 210 deletions(-) diff --git a/common/group.ts b/common/group.ts index 19f3b7b8..36654101 100644 --- a/common/group.ts +++ b/common/group.ts @@ -12,7 +12,22 @@ export type Group = { aboutPostId?: string chatDisabled?: boolean mostRecentContractAddedTime?: number + /** @deprecated - members and contracts now stored as subcollections*/ + memberIds?: string[] // Deprecated + /** @deprecated - members and contracts now stored as subcollections*/ + contractIds?: string[] // Deprecated + cachedLeaderboard?: { + topTraders: { + userId: string + score: number + }[] + topCreators: { + userId: string + score: number + }[] + } } + export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 430f3d33..273cd098 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -4,9 +4,11 @@ import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract, CPMM } from '../../common/contract' + import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' +import { scoreTraders, scoreCreators } from '../../common/scoring' import { calculateCreatorVolume, calculateNewPortfolioMetrics, @@ -15,6 +17,7 @@ import { computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' +import { Group } from 'common/group' const firestore = admin.firestore() @@ -24,16 +27,29 @@ export const updateMetrics = functions .onRun(updateMetricsCore) export async function updateMetricsCore() { - const [users, contracts, bets, allPortfolioHistories] = await Promise.all([ - getValues<User>(firestore.collection('users')), - getValues<Contract>(firestore.collection('contracts')), - getValues<Bet>(firestore.collectionGroup('bets')), - getValues<PortfolioMetrics>( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - ]) + const [users, contracts, bets, allPortfolioHistories, groups] = + await Promise.all([ + getValues<User>(firestore.collection('users')), + getValues<Contract>(firestore.collection('contracts')), + getValues<Bet>(firestore.collectionGroup('bets')), + getValues<PortfolioMetrics>( + firestore + .collectionGroup('portfolioHistory') + .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago + ), + getValues<Group>(firestore.collection('groups')), + ]) + + const contractsByGroup = await Promise.all( + groups.map((group) => { + return getValues( + firestore + .collection('groups') + .doc(group.id) + .collection('groupContracts') + ) + }) + ) log( `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` ) @@ -162,4 +178,40 @@ export async function updateMetricsCore() { 'set' ) log(`Updated metrics for ${users.length} users.`) + + const groupUpdates = groups.map((group, index) => { + const groupContractIds = contractsByGroup[index] as GroupContractDoc[] + const groupContracts = groupContractIds.map( + (e) => contractsById[e.contractId] + ) + const bets = groupContracts.map((e) => { + return betsByContract[e.id] ?? [] + }) + + const creatorScores = scoreCreators(groupContracts) + const traderScores = scoreTraders(groupContracts, bets) + + const topTraderScores = topUserScores(traderScores) + const topCreatorScores = topUserScores(creatorScores) + + return { + doc: firestore.collection('groups').doc(group.id), + fields: { + cachedLeaderboard: { + topTraders: topTraderScores, + topCreators: topCreatorScores, + }, + }, + } + }) + await writeAsync(firestore, groupUpdates) } + +const topUserScores = (scores: { [userId: string]: number }) => { + const top50 = Object.entries(scores) + .sort(([, scoreA], [, scoreB]) => scoreB - scoreA) + .slice(0, 50) + return top50.map(([userId, score]) => ({ userId, score })) +} + +type GroupContractDoc = { contractId: string; createdTime: number } diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 7a372d9a..f27460d9 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -24,7 +24,6 @@ import { Contract } from 'common/contract' import { getContractFromId, updateContract } from 'web/lib/firebase/contracts' import { db } from 'web/lib/firebase/init' import { filterDefined } from 'common/util/array' -import { getUser } from 'web/lib/firebase/users' export const groups = coll<Group>('groups') export const groupMembers = (groupId: string) => @@ -253,7 +252,7 @@ export function getGroupLinkToDisplay(contract: Contract) { return groupToDisplay } -export async function listMembers(group: Group) { +export async function listMemberIds(group: Group) { const members = await getValues<GroupMemberDoc>(groupMembers(group.id)) - return await Promise.all(members.map((m) => m.userId).map(getUser)) + return members.map((m) => m.userId) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 768e2f82..f5d68e57 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,28 +1,28 @@ import React, { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' -import { debounce, sortBy, take } from 'lodash' -import { SearchIcon } from '@heroicons/react/outline' import { toast } from 'react-hot-toast' import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Page } from 'web/components/page' -import { listAllBets } from 'web/lib/firebase/bets' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { addContractToGroup, getGroupBySlug, groupPath, joinGroup, - listMembers, + listMemberIds, updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group' -import { scoreCreators, scoreTraders } from 'common/scoring' +import { + useGroup, + useGroupContractIds, + useMemberIds, +} from 'web/hooks/use-group' import { Leaderboard } from 'web/components/leaderboard' import { formatMoney } from 'common/util/format' import { EditGroupButton } from 'web/components/groups/edit-group-button' @@ -35,9 +35,7 @@ import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ContractSearch } from 'web/components/contract-search' -import { FollowList } from 'web/components/follow-list' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' -import { searchInAny } from 'common/util/parse' import { CopyLinkButton } from 'web/components/copy-link-button' import { ENV_CONFIG } from 'common/envs/constants' import { useSaveReferral } from 'web/hooks/use-save-referral' @@ -59,7 +57,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const { slugs } = props.params const group = await getGroupBySlug(slugs[0]) - const members = group && (await listMembers(group)) + const memberIds = group && (await listMemberIds(group)) const creatorPromise = group ? getUser(group.creatorId) : null const contracts = @@ -71,19 +69,15 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { : 'open' const aboutPost = group && group.aboutPostId != null && (await getPost(group.aboutPostId)) - const bets = await Promise.all( - contracts.map((contract: Contract) => listAllBets(contract.id)) - ) const messages = group && (await listAllCommentsOnGroup(group.id)) - const creatorScores = scoreCreators(contracts) - const traderScores = scoreTraders(contracts, bets) - const [topCreators, topTraders] = - (members && [ - toTopUsers(creatorScores, members), - toTopUsers(traderScores, members), - ]) ?? - [] + const cachedTopTraderIds = + (group && group.cachedLeaderboard?.topTraders) ?? [] + const cachedTopCreatorIds = + (group && group.cachedLeaderboard?.topCreators) ?? [] + const topTraders = await toTopUsers(cachedTopTraderIds) + + const topCreators = await toTopUsers(cachedTopCreatorIds) const creator = await creatorPromise // Only count unresolved markets @@ -93,11 +87,9 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { props: { contractsCount, group, - members, + memberIds, creator, - traderScores, topTraders, - creatorScores, topCreators, messages, aboutPost, @@ -107,19 +99,6 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { revalidate: 60, // regenerate after a minute } } - -function toTopUsers(userScores: { [userId: string]: number }, users: User[]) { - const topUserPairs = take( - sortBy(Object.entries(userScores), ([_, score]) => -1 * score), - 10 - ).filter(([_, score]) => score >= 0.5) - - const topUsers = topUserPairs.map( - ([userId]) => users.filter((user) => user.id === userId)[0] - ) - return topUsers.filter((user) => user) -} - export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } @@ -134,12 +113,10 @@ const groupSubpages = [ export default function GroupPage(props: { contractsCount: number group: Group | null - members: User[] + memberIds: string[] creator: User - traderScores: { [userId: string]: number } - topTraders: User[] - creatorScores: { [userId: string]: number } - topCreators: User[] + topTraders: { user: User; score: number }[] + topCreators: { user: User; score: number }[] messages: GroupComment[] aboutPost: Post suggestedFilter: 'open' | 'all' @@ -147,24 +124,15 @@ export default function GroupPage(props: { props = usePropz(props, getStaticPropz) ?? { contractsCount: 0, group: null, - members: [], + memberIds: [], creator: null, - traderScores: {}, topTraders: [], - creatorScores: {}, topCreators: [], messages: [], suggestedFilter: 'open', } - const { - contractsCount, - creator, - traderScores, - topTraders, - creatorScores, - topCreators, - suggestedFilter, - } = props + const { contractsCount, creator, topTraders, topCreators, suggestedFilter } = + props const router = useRouter() const { slugs } = router.query as { slugs: string[] } @@ -175,7 +143,7 @@ export default function GroupPage(props: { const user = useUser() const isAdmin = useAdmin() - const members = useMembers(group?.id) ?? props.members + const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds useSaveReferral(user, { defaultReferrerUsername: creator.username, @@ -186,18 +154,25 @@ export default function GroupPage(props: { return <Custom404 /> } const isCreator = user && group && user.id === group.creatorId - const isMember = user && members.map((m) => m.id).includes(user.id) + const isMember = user && memberIds.includes(user.id) + const maxLeaderboardSize = 50 const leaderboard = ( <Col> - <GroupLeaderboards - traderScores={traderScores} - creatorScores={creatorScores} - topTraders={topTraders} - topCreators={topCreators} - members={members} - user={user} - /> + <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> + <GroupLeaderboard + topUsers={topTraders} + title="🏅 Top traders" + header="Profit" + maxToShow={maxLeaderboardSize} + /> + <GroupLeaderboard + topUsers={topCreators} + title="🏅 Top creators" + header="Market volume" + maxToShow={maxLeaderboardSize} + /> + </div> </Col> ) @@ -216,7 +191,7 @@ export default function GroupPage(props: { creator={creator} isCreator={!!isCreator} user={user} - members={members} + memberIds={memberIds} /> </Col> ) @@ -312,9 +287,9 @@ function GroupOverview(props: { creator: User user: User | null | undefined isCreator: boolean - members: User[] + memberIds: string[] }) { - const { group, creator, isCreator, user, members } = props + const { group, creator, isCreator, user, memberIds } = props const anyoneCanJoinChoices: { [key: string]: string } = { Closed: 'false', Open: 'true', @@ -333,7 +308,7 @@ function GroupOverview(props: { const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( group.slug )}${postFix}` - const isMember = user ? members.map((m) => m.id).includes(user.id) : false + const isMember = user ? memberIds.includes(user.id) : false return ( <> @@ -399,155 +374,37 @@ function GroupOverview(props: { /> </Col> )} - - <Col className={'mt-2'}> - <div className="mb-2 text-lg">Members</div> - <GroupMemberSearch members={members} group={group} /> - </Col> </Col> </> ) } -function SearchBar(props: { setQuery: (query: string) => void }) { - const { setQuery } = props - const debouncedQuery = debounce(setQuery, 50) - return ( - <div className={'relative'}> - <SearchIcon className={'absolute left-5 top-3.5 h-5 w-5 text-gray-500'} /> - <input - type="text" - onChange={(e) => debouncedQuery(e.target.value)} - placeholder="Find a member" - className="input input-bordered mb-4 w-full pl-12" - /> - </div> - ) -} - -function GroupMemberSearch(props: { members: User[]; group: Group }) { - const [query, setQuery] = useState('') - const { group } = props - let { members } = props - - // Use static members on load, but also listen to member changes: - const listenToMembers = useMembers(group.id) - if (listenToMembers) { - members = listenToMembers - } - - // TODO use find-active-contracts to sort by? - const matches = sortBy(members, [(member) => member.name]).filter((m) => - searchInAny(query, m.name, m.username) - ) - const matchLimit = 25 - - return ( - <div> - <SearchBar setQuery={setQuery} /> - <Col className={'gap-2'}> - {matches.length > 0 && ( - <FollowList userIds={matches.slice(0, matchLimit).map((m) => m.id)} /> - )} - {matches.length > 25 && ( - <div className={'text-center'}> - And {matches.length - matchLimit} more... - </div> - )} - </Col> - </div> - ) -} - -function SortedLeaderboard(props: { - users: User[] - scoreFunction: (user: User) => number +function GroupLeaderboard(props: { + topUsers: { user: User; score: number }[] title: string + maxToShow: number header: string - maxToShow?: number }) { - const { users, scoreFunction, title, header, maxToShow } = props - const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a)) + const { topUsers, title, maxToShow, header } = props + + const scoresByUser = topUsers.reduce((acc, { user, score }) => { + acc[user.id] = score + return acc + }, {} as { [key: string]: number }) + return ( <Leaderboard className="max-w-xl" - users={sortedUsers} + users={topUsers.map((t) => t.user)} title={title} columns={[ - { header, renderCell: (user) => formatMoney(scoreFunction(user)) }, + { header, renderCell: (user) => formatMoney(scoresByUser[user.id]) }, ]} maxToShow={maxToShow} /> ) } -function GroupLeaderboards(props: { - traderScores: { [userId: string]: number } - creatorScores: { [userId: string]: number } - topTraders: User[] - topCreators: User[] - members: User[] - user: User | null | undefined -}) { - const { traderScores, creatorScores, members, topTraders, topCreators } = - props - const maxToShow = 50 - // Consider hiding M$0 - // If it's just one member (curator), show all bettors, otherwise just show members - return ( - <Col> - <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> - {members.length > 1 ? ( - <> - <SortedLeaderboard - users={members} - scoreFunction={(user) => traderScores[user.id] ?? 0} - title="🏅 Top traders" - header="Profit" - maxToShow={maxToShow} - /> - <SortedLeaderboard - users={members} - scoreFunction={(user) => creatorScores[user.id] ?? 0} - title="🏅 Top creators" - header="Market volume" - maxToShow={maxToShow} - /> - </> - ) : ( - <> - <Leaderboard - className="max-w-xl" - title="🏅 Top traders" - users={topTraders} - columns={[ - { - header: 'Profit', - renderCell: (user) => formatMoney(traderScores[user.id] ?? 0), - }, - ]} - maxToShow={maxToShow} - /> - <Leaderboard - className="max-w-xl" - title="🏅 Top creators" - users={topCreators} - columns={[ - { - header: 'Market volume', - renderCell: (user) => - formatMoney(creatorScores[user.id] ?? 0), - }, - ]} - maxToShow={maxToShow} - /> - </> - )} - </div> - </Col> - ) -} - function AddContractButton(props: { group: Group; user: User }) { const { group, user } = props const [open, setOpen] = useState(false) @@ -684,3 +541,15 @@ function JoinGroupButton(props: { </div> ) } + +const toTopUsers = async ( + cachedUserIds: { userId: string; score: number }[] +): Promise<{ user: User; score: number }[]> => + ( + await Promise.all( + cachedUserIds.map(async (e) => { + const user = await getUser(e.userId) + return { user, score: e.score ?? 0 } + }) + ) + ).filter((e) => e.user != null) From 28f0c6b1f8f14bd210d02b6032967822d1779b3b Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Mon, 12 Sep 2022 17:26:46 +0100 Subject: [PATCH 09/76] Revert "Fix "500 internal error" in large groups (#856)" (#871) This reverts commit a6ed8c92282dd26310ee6b7c029365065ec53fdd. --- common/group.ts | 15 -- functions/src/update-metrics.ts | 72 +------- web/lib/firebase/groups.ts | 5 +- web/pages/group/[...slugs]/index.tsx | 263 ++++++++++++++++++++------- 4 files changed, 210 insertions(+), 145 deletions(-) diff --git a/common/group.ts b/common/group.ts index 36654101..19f3b7b8 100644 --- a/common/group.ts +++ b/common/group.ts @@ -12,22 +12,7 @@ export type Group = { aboutPostId?: string chatDisabled?: boolean mostRecentContractAddedTime?: number - /** @deprecated - members and contracts now stored as subcollections*/ - memberIds?: string[] // Deprecated - /** @deprecated - members and contracts now stored as subcollections*/ - contractIds?: string[] // Deprecated - cachedLeaderboard?: { - topTraders: { - userId: string - score: number - }[] - topCreators: { - userId: string - score: number - }[] - } } - export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 273cd098..430f3d33 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -4,11 +4,9 @@ import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract, CPMM } from '../../common/contract' - import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' -import { scoreTraders, scoreCreators } from '../../common/scoring' import { calculateCreatorVolume, calculateNewPortfolioMetrics, @@ -17,7 +15,6 @@ import { computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' -import { Group } from 'common/group' const firestore = admin.firestore() @@ -27,29 +24,16 @@ export const updateMetrics = functions .onRun(updateMetricsCore) export async function updateMetricsCore() { - const [users, contracts, bets, allPortfolioHistories, groups] = - await Promise.all([ - getValues<User>(firestore.collection('users')), - getValues<Contract>(firestore.collection('contracts')), - getValues<Bet>(firestore.collectionGroup('bets')), - getValues<PortfolioMetrics>( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - getValues<Group>(firestore.collection('groups')), - ]) - - const contractsByGroup = await Promise.all( - groups.map((group) => { - return getValues( - firestore - .collection('groups') - .doc(group.id) - .collection('groupContracts') - ) - }) - ) + const [users, contracts, bets, allPortfolioHistories] = await Promise.all([ + getValues<User>(firestore.collection('users')), + getValues<Contract>(firestore.collection('contracts')), + getValues<Bet>(firestore.collectionGroup('bets')), + getValues<PortfolioMetrics>( + firestore + .collectionGroup('portfolioHistory') + .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago + ), + ]) log( `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` ) @@ -178,40 +162,4 @@ export async function updateMetricsCore() { 'set' ) log(`Updated metrics for ${users.length} users.`) - - const groupUpdates = groups.map((group, index) => { - const groupContractIds = contractsByGroup[index] as GroupContractDoc[] - const groupContracts = groupContractIds.map( - (e) => contractsById[e.contractId] - ) - const bets = groupContracts.map((e) => { - return betsByContract[e.id] ?? [] - }) - - const creatorScores = scoreCreators(groupContracts) - const traderScores = scoreTraders(groupContracts, bets) - - const topTraderScores = topUserScores(traderScores) - const topCreatorScores = topUserScores(creatorScores) - - return { - doc: firestore.collection('groups').doc(group.id), - fields: { - cachedLeaderboard: { - topTraders: topTraderScores, - topCreators: topCreatorScores, - }, - }, - } - }) - await writeAsync(firestore, groupUpdates) } - -const topUserScores = (scores: { [userId: string]: number }) => { - const top50 = Object.entries(scores) - .sort(([, scoreA], [, scoreB]) => scoreB - scoreA) - .slice(0, 50) - return top50.map(([userId, score]) => ({ userId, score })) -} - -type GroupContractDoc = { contractId: string; createdTime: number } diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index f27460d9..7a372d9a 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -24,6 +24,7 @@ import { Contract } from 'common/contract' import { getContractFromId, updateContract } from 'web/lib/firebase/contracts' import { db } from 'web/lib/firebase/init' import { filterDefined } from 'common/util/array' +import { getUser } from 'web/lib/firebase/users' export const groups = coll<Group>('groups') export const groupMembers = (groupId: string) => @@ -252,7 +253,7 @@ export function getGroupLinkToDisplay(contract: Contract) { return groupToDisplay } -export async function listMemberIds(group: Group) { +export async function listMembers(group: Group) { const members = await getValues<GroupMemberDoc>(groupMembers(group.id)) - return members.map((m) => m.userId) + return await Promise.all(members.map((m) => m.userId).map(getUser)) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index f5d68e57..768e2f82 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,28 +1,28 @@ import React, { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' +import { debounce, sortBy, take } from 'lodash' +import { SearchIcon } from '@heroicons/react/outline' import { toast } from 'react-hot-toast' import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Page } from 'web/components/page' +import { listAllBets } from 'web/lib/firebase/bets' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { addContractToGroup, getGroupBySlug, groupPath, joinGroup, - listMemberIds, + listMembers, updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { - useGroup, - useGroupContractIds, - useMemberIds, -} from 'web/hooks/use-group' +import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group' +import { scoreCreators, scoreTraders } from 'common/scoring' import { Leaderboard } from 'web/components/leaderboard' import { formatMoney } from 'common/util/format' import { EditGroupButton } from 'web/components/groups/edit-group-button' @@ -35,7 +35,9 @@ import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ContractSearch } from 'web/components/contract-search' +import { FollowList } from 'web/components/follow-list' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' +import { searchInAny } from 'common/util/parse' import { CopyLinkButton } from 'web/components/copy-link-button' import { ENV_CONFIG } from 'common/envs/constants' import { useSaveReferral } from 'web/hooks/use-save-referral' @@ -57,7 +59,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const { slugs } = props.params const group = await getGroupBySlug(slugs[0]) - const memberIds = group && (await listMemberIds(group)) + const members = group && (await listMembers(group)) const creatorPromise = group ? getUser(group.creatorId) : null const contracts = @@ -69,15 +71,19 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { : 'open' const aboutPost = group && group.aboutPostId != null && (await getPost(group.aboutPostId)) + const bets = await Promise.all( + contracts.map((contract: Contract) => listAllBets(contract.id)) + ) const messages = group && (await listAllCommentsOnGroup(group.id)) - const cachedTopTraderIds = - (group && group.cachedLeaderboard?.topTraders) ?? [] - const cachedTopCreatorIds = - (group && group.cachedLeaderboard?.topCreators) ?? [] - const topTraders = await toTopUsers(cachedTopTraderIds) - - const topCreators = await toTopUsers(cachedTopCreatorIds) + const creatorScores = scoreCreators(contracts) + const traderScores = scoreTraders(contracts, bets) + const [topCreators, topTraders] = + (members && [ + toTopUsers(creatorScores, members), + toTopUsers(traderScores, members), + ]) ?? + [] const creator = await creatorPromise // Only count unresolved markets @@ -87,9 +93,11 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { props: { contractsCount, group, - memberIds, + members, creator, + traderScores, topTraders, + creatorScores, topCreators, messages, aboutPost, @@ -99,6 +107,19 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { revalidate: 60, // regenerate after a minute } } + +function toTopUsers(userScores: { [userId: string]: number }, users: User[]) { + const topUserPairs = take( + sortBy(Object.entries(userScores), ([_, score]) => -1 * score), + 10 + ).filter(([_, score]) => score >= 0.5) + + const topUsers = topUserPairs.map( + ([userId]) => users.filter((user) => user.id === userId)[0] + ) + return topUsers.filter((user) => user) +} + export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } @@ -113,10 +134,12 @@ const groupSubpages = [ export default function GroupPage(props: { contractsCount: number group: Group | null - memberIds: string[] + members: User[] creator: User - topTraders: { user: User; score: number }[] - topCreators: { user: User; score: number }[] + traderScores: { [userId: string]: number } + topTraders: User[] + creatorScores: { [userId: string]: number } + topCreators: User[] messages: GroupComment[] aboutPost: Post suggestedFilter: 'open' | 'all' @@ -124,15 +147,24 @@ export default function GroupPage(props: { props = usePropz(props, getStaticPropz) ?? { contractsCount: 0, group: null, - memberIds: [], + members: [], creator: null, + traderScores: {}, topTraders: [], + creatorScores: {}, topCreators: [], messages: [], suggestedFilter: 'open', } - const { contractsCount, creator, topTraders, topCreators, suggestedFilter } = - props + const { + contractsCount, + creator, + traderScores, + topTraders, + creatorScores, + topCreators, + suggestedFilter, + } = props const router = useRouter() const { slugs } = router.query as { slugs: string[] } @@ -143,7 +175,7 @@ export default function GroupPage(props: { const user = useUser() const isAdmin = useAdmin() - const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds + const members = useMembers(group?.id) ?? props.members useSaveReferral(user, { defaultReferrerUsername: creator.username, @@ -154,25 +186,18 @@ export default function GroupPage(props: { return <Custom404 /> } const isCreator = user && group && user.id === group.creatorId - const isMember = user && memberIds.includes(user.id) - const maxLeaderboardSize = 50 + const isMember = user && members.map((m) => m.id).includes(user.id) const leaderboard = ( <Col> - <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> - <GroupLeaderboard - topUsers={topTraders} - title="🏅 Top traders" - header="Profit" - maxToShow={maxLeaderboardSize} - /> - <GroupLeaderboard - topUsers={topCreators} - title="🏅 Top creators" - header="Market volume" - maxToShow={maxLeaderboardSize} - /> - </div> + <GroupLeaderboards + traderScores={traderScores} + creatorScores={creatorScores} + topTraders={topTraders} + topCreators={topCreators} + members={members} + user={user} + /> </Col> ) @@ -191,7 +216,7 @@ export default function GroupPage(props: { creator={creator} isCreator={!!isCreator} user={user} - memberIds={memberIds} + members={members} /> </Col> ) @@ -287,9 +312,9 @@ function GroupOverview(props: { creator: User user: User | null | undefined isCreator: boolean - memberIds: string[] + members: User[] }) { - const { group, creator, isCreator, user, memberIds } = props + const { group, creator, isCreator, user, members } = props const anyoneCanJoinChoices: { [key: string]: string } = { Closed: 'false', Open: 'true', @@ -308,7 +333,7 @@ function GroupOverview(props: { const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( group.slug )}${postFix}` - const isMember = user ? memberIds.includes(user.id) : false + const isMember = user ? members.map((m) => m.id).includes(user.id) : false return ( <> @@ -374,37 +399,155 @@ function GroupOverview(props: { /> </Col> )} + + <Col className={'mt-2'}> + <div className="mb-2 text-lg">Members</div> + <GroupMemberSearch members={members} group={group} /> + </Col> </Col> </> ) } -function GroupLeaderboard(props: { - topUsers: { user: User; score: number }[] +function SearchBar(props: { setQuery: (query: string) => void }) { + const { setQuery } = props + const debouncedQuery = debounce(setQuery, 50) + return ( + <div className={'relative'}> + <SearchIcon className={'absolute left-5 top-3.5 h-5 w-5 text-gray-500'} /> + <input + type="text" + onChange={(e) => debouncedQuery(e.target.value)} + placeholder="Find a member" + className="input input-bordered mb-4 w-full pl-12" + /> + </div> + ) +} + +function GroupMemberSearch(props: { members: User[]; group: Group }) { + const [query, setQuery] = useState('') + const { group } = props + let { members } = props + + // Use static members on load, but also listen to member changes: + const listenToMembers = useMembers(group.id) + if (listenToMembers) { + members = listenToMembers + } + + // TODO use find-active-contracts to sort by? + const matches = sortBy(members, [(member) => member.name]).filter((m) => + searchInAny(query, m.name, m.username) + ) + const matchLimit = 25 + + return ( + <div> + <SearchBar setQuery={setQuery} /> + <Col className={'gap-2'}> + {matches.length > 0 && ( + <FollowList userIds={matches.slice(0, matchLimit).map((m) => m.id)} /> + )} + {matches.length > 25 && ( + <div className={'text-center'}> + And {matches.length - matchLimit} more... + </div> + )} + </Col> + </div> + ) +} + +function SortedLeaderboard(props: { + users: User[] + scoreFunction: (user: User) => number title: string - maxToShow: number header: string + maxToShow?: number }) { - const { topUsers, title, maxToShow, header } = props - - const scoresByUser = topUsers.reduce((acc, { user, score }) => { - acc[user.id] = score - return acc - }, {} as { [key: string]: number }) - + const { users, scoreFunction, title, header, maxToShow } = props + const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a)) return ( <Leaderboard className="max-w-xl" - users={topUsers.map((t) => t.user)} + users={sortedUsers} title={title} columns={[ - { header, renderCell: (user) => formatMoney(scoresByUser[user.id]) }, + { header, renderCell: (user) => formatMoney(scoreFunction(user)) }, ]} maxToShow={maxToShow} /> ) } +function GroupLeaderboards(props: { + traderScores: { [userId: string]: number } + creatorScores: { [userId: string]: number } + topTraders: User[] + topCreators: User[] + members: User[] + user: User | null | undefined +}) { + const { traderScores, creatorScores, members, topTraders, topCreators } = + props + const maxToShow = 50 + // Consider hiding M$0 + // If it's just one member (curator), show all bettors, otherwise just show members + return ( + <Col> + <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> + {members.length > 1 ? ( + <> + <SortedLeaderboard + users={members} + scoreFunction={(user) => traderScores[user.id] ?? 0} + title="🏅 Top traders" + header="Profit" + maxToShow={maxToShow} + /> + <SortedLeaderboard + users={members} + scoreFunction={(user) => creatorScores[user.id] ?? 0} + title="🏅 Top creators" + header="Market volume" + maxToShow={maxToShow} + /> + </> + ) : ( + <> + <Leaderboard + className="max-w-xl" + title="🏅 Top traders" + users={topTraders} + columns={[ + { + header: 'Profit', + renderCell: (user) => formatMoney(traderScores[user.id] ?? 0), + }, + ]} + maxToShow={maxToShow} + /> + <Leaderboard + className="max-w-xl" + title="🏅 Top creators" + users={topCreators} + columns={[ + { + header: 'Market volume', + renderCell: (user) => + formatMoney(creatorScores[user.id] ?? 0), + }, + ]} + maxToShow={maxToShow} + /> + </> + )} + </div> + </Col> + ) +} + function AddContractButton(props: { group: Group; user: User }) { const { group, user } = props const [open, setOpen] = useState(false) @@ -541,15 +684,3 @@ function JoinGroupButton(props: { </div> ) } - -const toTopUsers = async ( - cachedUserIds: { userId: string; score: number }[] -): Promise<{ user: User; score: number }[]> => - ( - await Promise.all( - cachedUserIds.map(async (e) => { - const user = await getUser(e.userId) - return { user, score: e.score ?? 0 } - }) - ) - ).filter((e) => e.user != null) From 5c6328ffc263fb2bcbe8cbb6862b6bc488208fb5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 10:34:56 -0600 Subject: [PATCH 10/76] [WIP] Fully customizable notifications (#860) * Notifications Settings page working * Update import * Linked notification settings to notification rules * Add more subscribe types * It's alive... It's alive, it's moving, it's alive, it's alive, it's alive, it's alive, IT'S ALIVE' * UI Tweaks * Clean up comments * Direct & highlight sections for notif mgmt from emails * Comment cleanup * Comment cleanup, lint * More comment cleanup * Update email templates to predict * Move private user out of getDestinationsForUser * Fix resolution messages * Remove magic * Extract switch to switch-setting * Change tab in url * Show 0 as invested or payout * All emails use unsubscribeUrl --- common/notification.ts | 105 ++- common/user.ts | 190 +++++ firebase.json | 22 +- firestore.rules | 8 +- functions/.gitignore | 1 + functions/src/create-answer.ts | 7 +- functions/src/create-group.ts | 2 +- functions/src/create-notification.ts | 633 ++++++++------- functions/src/create-user.ts | 7 +- functions/src/email-templates/500-mana.html | 9 +- .../src/email-templates/creating-market.html | 4 +- .../email-templates/interesting-markets.html | 5 +- .../market-answer-comment.html | 17 +- .../src/email-templates/market-answer.html | 11 +- .../src/email-templates/market-close.html | 11 +- .../src/email-templates/market-comment.html | 11 +- .../src/email-templates/market-resolved.html | 11 +- functions/src/email-templates/one-week.html | 753 +++++++----------- functions/src/email-templates/thank-you.html | 10 +- functions/src/email-templates/welcome.html | 11 +- functions/src/emails.ts | 165 ++-- functions/src/market-close-notifications.ts | 2 - .../src/on-create-comment-on-contract.ts | 40 +- functions/src/on-update-contract.ts | 27 +- functions/src/resolve-market.ts | 95 +-- .../create-new-notification-preferences.ts | 30 + functions/src/scripts/create-private-users.ts | 3 +- web/components/NotificationSettings.tsx | 236 ------ ...arket-modal.tsx => watch-market-modal.tsx} | 11 +- web/components/follow-market-button.tsx | 4 +- web/components/notification-settings.tsx | 320 ++++++++ web/components/switch-setting.tsx | 34 + web/hooks/use-notifications.ts | 35 +- web/pages/notifications.tsx | 49 +- 34 files changed, 1554 insertions(+), 1325 deletions(-) create mode 100644 functions/src/scripts/create-new-notification-preferences.ts delete mode 100644 web/components/NotificationSettings.tsx rename web/components/contract/{follow-market-modal.tsx => watch-market-modal.tsx} (74%) create mode 100644 web/components/notification-settings.tsx create mode 100644 web/components/switch-setting.tsx diff --git a/common/notification.ts b/common/notification.ts index 9ec320fa..42dbbf35 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -1,3 +1,6 @@ +import { notification_subscription_types, PrivateUser } from './user' +import { DOMAIN } from './envs/constants' + export type Notification = { id: string userId: string @@ -51,28 +54,106 @@ export type notification_source_update_types = | 'deleted' | 'closed' +/* Optional - if possible use a keyof notification_subscription_types */ export type notification_reason_types = | 'tagged_user' - | 'on_users_contract' - | 'on_contract_with_users_shares_in' - | 'on_contract_with_users_shares_out' - | 'on_contract_with_users_answer' - | 'on_contract_with_users_comment' - | 'reply_to_users_answer' - | 'reply_to_users_comment' | 'on_new_follow' - | 'you_follow_user' - | 'added_you_to_group' + | 'contract_from_followed_user' | 'you_referred_user' | 'user_joined_to_bet_on_your_market' | 'unique_bettors_on_your_contract' - | 'on_group_you_are_member_of' | 'tip_received' | 'bet_fill' | 'user_joined_from_your_group_invite' | 'challenge_accepted' | 'betting_streak_incremented' | 'loan_income' - | 'you_follow_contract' - | 'liked_your_contract' | 'liked_and_tipped_your_contract' + | 'comment_on_your_contract' + | 'answer_on_your_contract' + | 'comment_on_contract_you_follow' + | 'answer_on_contract_you_follow' + | 'update_on_contract_you_follow' + | 'resolution_on_contract_you_follow' + | 'comment_on_contract_with_users_shares_in' + | 'answer_on_contract_with_users_shares_in' + | 'update_on_contract_with_users_shares_in' + | 'resolution_on_contract_with_users_shares_in' + | 'comment_on_contract_with_users_answer' + | 'update_on_contract_with_users_answer' + | 'resolution_on_contract_with_users_answer' + | 'answer_on_contract_with_users_answer' + | 'comment_on_contract_with_users_comment' + | 'answer_on_contract_with_users_comment' + | 'update_on_contract_with_users_comment' + | 'resolution_on_contract_with_users_comment' + | 'reply_to_users_answer' + | 'reply_to_users_comment' + | 'your_contract_closed' + | 'subsidized_your_market' + +// Adding a new key:value here is optional, you can just use a key of notification_subscription_types +// You might want to add a key:value here if there will be multiple notification reasons that map to the same +// subscription type, i.e. 'comment_on_contract_you_follow' and 'comment_on_contract_with_users_answer' both map to +// 'all_comments_on_watched_markets' subscription type +// TODO: perhaps better would be to map notification_subscription_types to arrays of notification_reason_types +export const notificationReasonToSubscriptionType: Partial< + Record<notification_reason_types, keyof notification_subscription_types> +> = { + 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.notificationSubscriptionTypes + 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] + : [] + } + return { + sendToEmail: destinations.includes('email'), + sendToBrowser: destinations.includes('browser'), + urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + } +} diff --git a/common/user.ts b/common/user.ts index f15865cf..f8b4f8d8 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,3 +1,5 @@ +import { filterDefined } from './util/array' + export type User = { id: string createdTime: number @@ -63,9 +65,60 @@ export type PrivateUser = { initialDeviceToken?: string initialIpAddress?: string apiKey?: string + /** @deprecated - use notificationSubscriptionTypes */ notificationPreferences?: notification_subscribe_types + notificationSubscriptionTypes: notification_subscription_types } +export type notification_destination_types = 'email' | 'browser' +export type notification_subscription_types = { + // Watched Markets + all_comments_on_watched_markets: notification_destination_types[] + all_answers_on_watched_markets: notification_destination_types[] + + // Comments + tipped_comments_on_watched_markets: notification_destination_types[] + comments_by_followed_users_on_watched_markets: notification_destination_types[] + all_replies_to_my_comments_on_watched_markets: notification_destination_types[] + all_replies_to_my_answers_on_watched_markets: notification_destination_types[] + all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[] + + // Answers + answers_by_followed_users_on_watched_markets: notification_destination_types[] + answers_by_market_creator_on_watched_markets: notification_destination_types[] + all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[] + + // On users' markets + your_contract_closed: notification_destination_types[] + all_comments_on_my_markets: notification_destination_types[] + all_answers_on_my_markets: notification_destination_types[] + subsidized_your_market: notification_destination_types[] + + // Market updates + resolutions_on_watched_markets: notification_destination_types[] + resolutions_on_watched_markets_with_shares_in: notification_destination_types[] + market_updates_on_watched_markets: notification_destination_types[] + market_updates_on_watched_markets_with_shares_in: notification_destination_types[] + probability_updates_on_watched_markets: notification_destination_types[] + + // Balance Changes + loan_income: notification_destination_types[] + betting_streaks: notification_destination_types[] + referral_bonuses: notification_destination_types[] + unique_bettors_on_your_contract: notification_destination_types[] + tips_on_your_comments: notification_destination_types[] + tips_on_your_markets: notification_destination_types[] + limit_order_fills: notification_destination_types[] + + // General + tagged_user: notification_destination_types[] + on_new_follow: notification_destination_types[] + contract_from_followed_user: notification_destination_types[] + trending_markets: notification_destination_types[] + profit_loss_updates: notification_destination_types[] + onboarding_flow: notification_destination_types[] + thank_you_for_purchases: notification_destination_types[] +} export type notification_subscribe_types = 'all' | 'less' | 'none' export type PortfolioMetrics = { @@ -78,3 +131,140 @@ 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 prevPref = privateUser?.notificationPreferences ?? 'all' + const wantsLess = prevPref === 'less' + const wantsAll = prevPref === 'all' + 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( + wantsAll, + !unsubscribedFromCommentEmails + ), + all_answers_on_watched_markets: constructPref( + wantsAll, + !unsubscribedFromAnswerEmails + ), + + // Comments + tips_on_your_comments: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + comments_by_followed_users_on_watched_markets: constructPref( + wantsAll, + false + ), + all_replies_to_my_comments_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + all_replies_to_my_answers_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref( + wantsAll, + !unsubscribedFromCommentEmails + ), + + // Answers + answers_by_followed_users_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromAnswerEmails + ), + answers_by_market_creator_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromAnswerEmails + ), + all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref( + wantsAll, + !unsubscribedFromAnswerEmails + ), + + // On users' markets + your_contract_closed: constructPref( + wantsAll || wantsLess, + !unsubscribedFromResolutionEmails + ), // High priority + all_comments_on_my_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + all_answers_on_my_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromAnswerEmails + ), + subsidized_your_market: constructPref(wantsAll || wantsLess, true), + + // Market updates + resolutions_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromResolutionEmails + ), + market_updates_on_watched_markets: constructPref( + wantsAll || wantsLess, + false + ), + market_updates_on_watched_markets_with_shares_in: constructPref( + wantsAll || wantsLess, + false + ), + resolutions_on_watched_markets_with_shares_in: constructPref( + wantsAll || wantsLess, + !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 + ), + tipped_comments_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + tips_on_your_markets: constructPref(wantsAll || wantsLess, true), + limit_order_fills: constructPref(wantsAll || wantsLess, false), + + // General + tagged_user: constructPref(wantsAll || wantsLess, true), + on_new_follow: constructPref(wantsAll || wantsLess, true), + contract_from_followed_user: constructPref(wantsAll || wantsLess, true), + trending_markets: constructPref( + false, + !unsubscribedFromWeeklyTrendingEmails + ), + profit_loss_updates: constructPref(false, true), + probability_updates_on_watched_markets: constructPref( + wantsAll || wantsLess, + false + ), + thank_you_for_purchases: constructPref( + false, + !unsubscribedFromGenericEmails + ), + onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails), + } as notification_subscription_types +} diff --git a/firebase.json b/firebase.json index 25f9b61f..5dea5ade 100644 --- a/firebase.json +++ b/firebase.json @@ -2,10 +2,30 @@ "functions": { "predeploy": "cd functions && yarn build", "runtime": "nodejs16", - "source": "functions/dist" + "source": "functions/dist", + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log" + ] }, "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" + }, + "emulators": { + "functions": { + "port": 5001 + }, + "firestore": { + "port": 8080 + }, + "pubsub": { + "port": 8085 + }, + "ui": { + "enabled": true + } } } diff --git a/firestore.rules b/firestore.rules index 9a72e454..d24d4097 100644 --- a/firestore.rules +++ b/firestore.rules @@ -77,7 +77,7 @@ service cloud.firestore { allow read: if userId == request.auth.uid || isAdmin(); allow update: if (userId == request.auth.uid || isAdmin()) && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]); + .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails','notificationSubscriptionTypes' ]); } 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; } @@ -170,7 +170,7 @@ service cloud.firestore { allow read; } - match /groups/{groupId} { + match /groups/{groupId} { allow read; allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) @@ -184,7 +184,7 @@ service cloud.firestore { match /groupMembers/{memberId}{ allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin); - allow delete: if request.auth.uid == resource.data.userId; + allow delete: if request.auth.uid == resource.data.userId; } function isGroupMember() { diff --git a/functions/.gitignore b/functions/.gitignore index 58f30dcb..bd3d0c29 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -17,4 +17,5 @@ package-lock.json ui-debug.log firebase-debug.log firestore-debug.log +pubsub-debug.log firestore_export/ diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index 0b8b4e7a..cc05d817 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -5,8 +5,7 @@ import { Contract } from '../../common/contract' import { User } from '../../common/user' import { getNewMultiBetInfo } from '../../common/new-bet' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' -import { getContract, getValues } from './utils' -import { sendNewAnswerEmail } from './emails' +import { getValues } from './utils' import { APIError, newEndpoint, validate } from './api' const bodySchema = z.object({ @@ -97,10 +96,6 @@ export const createanswer = newEndpoint(opts, async (req, auth) => { return answer }) - const contract = await getContract(contractId) - - if (answer && contract) await sendNewAnswerEmail(answer, contract) - return answer }) diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index fc64aeff..9d00bb0b 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -10,7 +10,7 @@ import { MAX_GROUP_NAME_LENGTH, MAX_ID_LENGTH, } from '../../common/group' -import { APIError, newEndpoint, validate } from '../../functions/src/api' +import { APIError, newEndpoint, validate } from './api' import { z } from 'zod' const bodySchema = z.object({ diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 131d6e85..356ad200 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -1,13 +1,12 @@ import * as admin from 'firebase-admin' import { + getDestinationsForUser, Notification, notification_reason_types, - notification_source_update_types, - notification_source_types, } from '../../common/notification' import { User } from '../../common/user' import { Contract } from '../../common/contract' -import { getValues, log } from './utils' +import { getPrivateUser, getValues } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' @@ -15,20 +14,25 @@ import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' -import { Group, GROUP_CHAT_SLUG } from '../../common/group' +import { Group } from '../../common/group' import { Challenge } from '../../common/challenge' -import { richTextToString } from '../../common/util/parse' import { Like } from '../../common/like' +import { + sendMarketCloseEmail, + sendMarketResolutionEmail, + sendNewAnswerEmail, + sendNewCommentEmail, +} from './emails' const firestore = admin.firestore() -type user_to_reason_texts = { +type recipients_to_reason_texts = { [userId: string]: { reason: notification_reason_types } } export const createNotification = async ( sourceId: string, - sourceType: notification_source_types, - sourceUpdateType: notification_source_update_types, + sourceType: 'contract' | 'liquidity' | 'follow', + sourceUpdateType: 'closed' | 'created', sourceUser: User, idempotencyKey: string, sourceText: string, @@ -41,9 +45,9 @@ export const createNotification = async ( ) => { const { contract: sourceContract, recipients, slug, title } = miscData ?? {} - const shouldGetNotification = ( + const shouldReceiveNotification = ( userId: string, - userToReasonTexts: user_to_reason_texts + userToReasonTexts: recipients_to_reason_texts ) => { return ( sourceUser.id != userId && @@ -51,18 +55,25 @@ export const createNotification = async ( ) } - const createUsersNotifications = async ( - userToReasonTexts: user_to_reason_texts + const sendNotificationsIfSettingsPermit = async ( + userToReasonTexts: recipients_to_reason_texts ) => { - await Promise.all( - Object.keys(userToReasonTexts).map(async (userId) => { + for (const userId in userToReasonTexts) { + const { reason } = userToReasonTexts[userId] + const privateUser = await getPrivateUser(userId) + if (!privateUser) continue + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + privateUser, + reason + ) + if (sendToBrowser) { const notificationRef = firestore .collection(`/users/${userId}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId, - reason: userToReasonTexts[userId].reason, + reason, createdTime: Date.now(), isSeen: false, sourceId, @@ -80,12 +91,32 @@ export const createNotification = async ( sourceTitle: title ? title : sourceContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) - }) - ) + } + + if (!sendToEmail) continue + + if (reason === 'your_contract_closed' && privateUser && sourceContract) { + // TODO: include number and names of bettors waiting for creator to resolve their market + await sendMarketCloseEmail( + reason, + sourceUser, + privateUser, + sourceContract + ) + } else if (reason === 'tagged_user') { + // TODO: send email to tagged user in new contract + } else if (reason === 'subsidized_your_market') { + // TODO: send email to creator of market that was subsidized + } else if (reason === 'contract_from_followed_user') { + // TODO: send email to follower of user who created market + } else if (reason === 'on_new_follow') { + // TODO: send email to user who was followed + } + } } const notifyUsersFollowers = async ( - userToReasonTexts: user_to_reason_texts + userToReasonTexts: recipients_to_reason_texts ) => { const followers = await firestore .collectionGroup('follows') @@ -96,72 +127,36 @@ export const createNotification = async ( const followerUserId = doc.ref.parent.parent?.id if ( followerUserId && - shouldGetNotification(followerUserId, userToReasonTexts) + shouldReceiveNotification(followerUserId, userToReasonTexts) ) { userToReasonTexts[followerUserId] = { - reason: 'you_follow_user', + reason: 'contract_from_followed_user', } } }) } - const notifyFollowedUser = ( - userToReasonTexts: user_to_reason_texts, - followedUserId: string - ) => { - if (shouldGetNotification(followedUserId, userToReasonTexts)) - userToReasonTexts[followedUserId] = { - reason: 'on_new_follow', - } - } - const notifyTaggedUsers = ( - userToReasonTexts: user_to_reason_texts, + userToReasonTexts: recipients_to_reason_texts, userIds: (string | undefined)[] ) => { userIds.forEach((id) => { - if (id && shouldGetNotification(id, userToReasonTexts)) + if (id && shouldReceiveNotification(id, userToReasonTexts)) userToReasonTexts[id] = { reason: 'tagged_user', } }) } - const notifyContractCreator = async ( - userToReasonTexts: user_to_reason_texts, - sourceContract: Contract, - options?: { force: boolean } - ) => { - if ( - options?.force || - shouldGetNotification(sourceContract.creatorId, userToReasonTexts) - ) - userToReasonTexts[sourceContract.creatorId] = { - reason: 'on_users_contract', - } - } - - const notifyUserAddedToGroup = ( - userToReasonTexts: user_to_reason_texts, - relatedUserId: string - ) => { - if (shouldGetNotification(relatedUserId, userToReasonTexts)) - userToReasonTexts[relatedUserId] = { - reason: 'added_you_to_group', - } - } - - const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. + const userToReasonTexts: recipients_to_reason_texts = {} if (sourceType === 'follow' && recipients?.[0]) { - notifyFollowedUser(userToReasonTexts, recipients[0]) - } else if ( - sourceType === 'group' && - sourceUpdateType === 'created' && - recipients - ) { - recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r)) + if (shouldReceiveNotification(recipients[0], userToReasonTexts)) + userToReasonTexts[recipients[0]] = { + reason: 'on_new_follow', + } + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'contract' && sourceUpdateType === 'created' && @@ -169,123 +164,198 @@ export const createNotification = async ( ) { await notifyUsersFollowers(userToReasonTexts) notifyTaggedUsers(userToReasonTexts, recipients ?? []) + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'contract' && sourceUpdateType === 'closed' && sourceContract ) { - await notifyContractCreator(userToReasonTexts, sourceContract, { - force: true, - }) + userToReasonTexts[sourceContract.creatorId] = { + reason: 'your_contract_closed', + } + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'liquidity' && sourceUpdateType === 'created' && sourceContract ) { - await notifyContractCreator(userToReasonTexts, sourceContract) + if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts)) + userToReasonTexts[sourceContract.creatorId] = { + reason: 'subsidized_your_market', + } + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } - - await createUsersNotifications(userToReasonTexts) } export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceId: string, - sourceType: notification_source_types, - sourceUpdateType: notification_source_update_types, + sourceType: 'comment' | 'answer' | 'contract', + sourceUpdateType: 'created' | 'updated' | 'resolved', sourceUser: User, idempotencyKey: string, sourceText: string, sourceContract: Contract, miscData?: { - relatedSourceType?: notification_source_types + repliedToType?: 'comment' | 'answer' + repliedToId?: string + repliedToContent?: string repliedUserId?: string taggedUserIds?: string[] + }, + resolutionData?: { + bets: Bet[] + userInvestments: { [userId: string]: number } + userPayouts: { [userId: string]: number } + creator: User + creatorPayout: number + contract: Contract + outcome: string + resolutionProbability?: number + resolutions?: { [outcome: string]: number } } ) => { - const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {} + const { + repliedToType, + repliedToContent, + repliedUserId, + taggedUserIds, + repliedToId, + } = miscData ?? {} - const createUsersNotifications = async ( - userToReasonTexts: user_to_reason_texts - ) => { - await Promise.all( - Object.keys(userToReasonTexts).map(async (userId) => { - const notificationRef = firestore - .collection(`/users/${userId}/notifications`) - .doc(idempotencyKey) - const notification: Notification = { - id: idempotencyKey, - userId, - reason: userToReasonTexts[userId].reason, - createdTime: Date.now(), - isSeen: false, - sourceId, - sourceType, - sourceUpdateType, - sourceContractId: sourceContract.id, - sourceUserName: sourceUser.name, - sourceUserUsername: sourceUser.username, - sourceUserAvatarUrl: sourceUser.avatarUrl, - sourceText, - sourceContractCreatorUsername: sourceContract.creatorUsername, - sourceContractTitle: sourceContract.question, - sourceContractSlug: sourceContract.slug, - sourceSlug: sourceContract.slug, - sourceTitle: sourceContract.question, - } - await notificationRef.set(removeUndefinedProps(notification)) - }) - ) - } + const recipientIdsList: string[] = [] - // get contract follower documents and check here if they're a follower const contractFollowersSnap = await firestore .collection(`contracts/${sourceContract.id}/follows`) .get() const contractFollowersIds = contractFollowersSnap.docs.map( (doc) => doc.data().id ) - log('contractFollowerIds', contractFollowersIds) + + const createBrowserNotification = async ( + userId: string, + reason: notification_reason_types + ) => { + const notificationRef = firestore + .collection(`/users/${userId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId, + reason, + createdTime: Date.now(), + isSeen: false, + sourceId, + sourceType, + sourceUpdateType, + sourceContractId: sourceContract.id, + sourceUserName: sourceUser.name, + sourceUserUsername: sourceUser.username, + sourceUserAvatarUrl: sourceUser.avatarUrl, + sourceText, + sourceContractCreatorUsername: sourceContract.creatorUsername, + sourceContractTitle: sourceContract.question, + sourceContractSlug: sourceContract.slug, + sourceSlug: sourceContract.slug, + sourceTitle: sourceContract.question, + } + return await notificationRef.set(removeUndefinedProps(notification)) + } const stillFollowingContract = (userId: string) => { return contractFollowersIds.includes(userId) } - const shouldGetNotification = ( + const sendNotificationsIfSettingsPermit = async ( userId: string, - userToReasonTexts: user_to_reason_texts + reason: notification_reason_types ) => { - return ( - sourceUser.id != userId && - !Object.keys(userToReasonTexts).includes(userId) + if ( + !stillFollowingContract(sourceContract.creatorId) || + sourceUser.id == userId || + recipientIdsList.includes(userId) + ) + return + const privateUser = await getPrivateUser(userId) + if (!privateUser) return + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + privateUser, + reason ) - } - const notifyContractFollowers = async ( - userToReasonTexts: user_to_reason_texts - ) => { - for (const userId of contractFollowersIds) { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'you_follow_contract', - } + if (sendToBrowser) { + await createBrowserNotification(userId, reason) + recipientIdsList.push(userId) + } + if (sendToEmail) { + if (sourceType === 'comment') { + // TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment + await sendNewCommentEmail( + reason, + privateUser, + sourceUser, + sourceContract, + sourceText, + sourceId, + // TODO: Add any paired bets to the comment + undefined, + repliedToType === 'answer' ? repliedToContent : undefined, + repliedToType === 'answer' ? repliedToId : undefined + ) + } else if (sourceType === 'answer') + await sendNewAnswerEmail( + reason, + privateUser, + sourceUser.name, + sourceText, + sourceContract, + sourceUser.avatarUrl + ) + 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 + ) + recipientIdsList.push(userId) } } - const notifyContractCreator = async ( - userToReasonTexts: user_to_reason_texts - ) => { - if ( - shouldGetNotification(sourceContract.creatorId, userToReasonTexts) && - stillFollowingContract(sourceContract.creatorId) - ) - userToReasonTexts[sourceContract.creatorId] = { - reason: 'on_users_contract', - } + const notifyContractFollowers = async () => { + for (const userId of contractFollowersIds) { + await sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_you_follow' + : sourceType === 'comment' + ? 'comment_on_contract_you_follow' + : sourceUpdateType === 'updated' + ? 'update_on_contract_you_follow' + : 'resolution_on_contract_you_follow' + ) + } } - const notifyOtherAnswerersOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyContractCreator = async () => { + await sendNotificationsIfSettingsPermit( + sourceContract.creatorId, + sourceType === 'comment' + ? 'comment_on_your_contract' + : 'answer_on_your_contract' + ) + } + + const notifyOtherAnswerersOnContract = async () => { const answers = await getValues<Answer>( firestore .collection('contracts') @@ -293,20 +363,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( .collection('answers') ) const recipientUserIds = uniq(answers.map((answer) => answer.userId)) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_answer' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_answer' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_answer' + : 'resolution_on_contract_with_users_answer' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_answer', - } - }) + ) } - const notifyOtherCommentersOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyOtherCommentersOnContract = async () => { const comments = await getValues<Comment>( firestore .collection('contracts') @@ -314,20 +387,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( .collection('comments') ) const recipientUserIds = uniq(comments.map((comment) => comment.userId)) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_comment' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_comment' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_comment' + : 'resolution_on_contract_with_users_comment' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_comment', - } - }) + ) } - const notifyBettorsOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyBettorsOnContract = async () => { const betsSnap = await firestore .collection(`contracts/${sourceContract.id}/bets`) .get() @@ -343,88 +419,73 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( ) } ) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_shares_in' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_shares_in' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_shares_in' + : 'resolution_on_contract_with_users_shares_in' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - }) + ) } - const notifyRepliedUser = ( - userToReasonTexts: user_to_reason_texts, - relatedUserId: string, - relatedSourceType: notification_source_types - ) => { - if ( - shouldGetNotification(relatedUserId, userToReasonTexts) && - stillFollowingContract(relatedUserId) - ) { - if (relatedSourceType === 'comment') { - userToReasonTexts[relatedUserId] = { - reason: 'reply_to_users_comment', - } - } else if (relatedSourceType === 'answer') { - userToReasonTexts[relatedUserId] = { - reason: 'reply_to_users_answer', - } - } - } + const notifyRepliedUser = async () => { + if (sourceType === 'comment' && repliedUserId && repliedToType) + await sendNotificationsIfSettingsPermit( + repliedUserId, + repliedToType === 'answer' + ? 'reply_to_users_answer' + : 'reply_to_users_comment' + ) } - const notifyTaggedUsers = ( - userToReasonTexts: user_to_reason_texts, - userIds: (string | undefined)[] - ) => { - userIds.forEach((id) => { - console.log('tagged user: ', id) - // Allowing non-following users to get tagged - if (id && shouldGetNotification(id, userToReasonTexts)) - userToReasonTexts[id] = { - reason: 'tagged_user', - } - }) + const notifyTaggedUsers = async () => { + if (sourceType === 'comment' && taggedUserIds && taggedUserIds.length > 0) + await Promise.all( + taggedUserIds.map((userId) => + sendNotificationsIfSettingsPermit(userId, 'tagged_user') + ) + ) } - const notifyLiquidityProviders = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyLiquidityProviders = async () => { const liquidityProviders = await firestore .collection(`contracts/${sourceContract.id}/liquidity`) .get() const liquidityProvidersIds = uniq( liquidityProviders.docs.map((doc) => doc.data().userId) ) - liquidityProvidersIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) - ) { - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - } - }) + await Promise.all( + liquidityProvidersIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_shares_in' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_shares_in' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_shares_in' + : 'resolution_on_contract_with_users_shares_in' + ) + ) + ) } - const userToReasonTexts: user_to_reason_texts = {} - if (sourceType === 'comment') { - if (repliedUserId && relatedSourceType) - notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType) - if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? []) - } - await notifyContractCreator(userToReasonTexts) - await notifyOtherAnswerersOnContract(userToReasonTexts) - await notifyLiquidityProviders(userToReasonTexts) - await notifyBettorsOnContract(userToReasonTexts) - await notifyOtherCommentersOnContract(userToReasonTexts) - // if they weren't added previously, add them now - await notifyContractFollowers(userToReasonTexts) - - await createUsersNotifications(userToReasonTexts) + await notifyRepliedUser() + await notifyTaggedUsers() + await notifyContractCreator() + await notifyOtherAnswerersOnContract() + await notifyLiquidityProviders() + await notifyBettorsOnContract() + await notifyOtherCommentersOnContract() + // if they weren't notified previously, notify them now + await notifyContractFollowers() } export const createTipNotification = async ( @@ -436,8 +497,15 @@ export const createTipNotification = async ( contract?: Contract, group?: Group ) => { - const slug = group ? group.slug + `#${commentId}` : commentId + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'tip_received' + ) + if (!sendToBrowser) return + const slug = group ? group.slug + `#${commentId}` : commentId const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -461,6 +529,9 @@ export const createTipNotification = async ( sourceTitle: group?.name, } return await notificationRef.set(removeUndefinedProps(notification)) + + // TODO: send notification to users that are watching the contract and want highly tipped comments only + // maybe TODO: send email notification to bet creator } export const createBetFillNotification = async ( @@ -471,6 +542,14 @@ export const createBetFillNotification = async ( contract: Contract, idempotencyKey: string ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'bet_fill' + ) + if (!sendToBrowser) return + const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id) const fillAmount = fill?.amount ?? 0 @@ -496,38 +575,8 @@ export const createBetFillNotification = async ( sourceContractId: contract.id, } return await notificationRef.set(removeUndefinedProps(notification)) -} -export const createGroupCommentNotification = async ( - fromUser: User, - toUserId: string, - comment: Comment, - group: Group, - idempotencyKey: string -) => { - if (toUserId === fromUser.id) return - const notificationRef = firestore - .collection(`/users/${toUserId}/notifications`) - .doc(idempotencyKey) - const sourceSlug = `/group/${group.slug}/${GROUP_CHAT_SLUG}` - const notification: Notification = { - id: idempotencyKey, - userId: toUserId, - reason: 'on_group_you_are_member_of', - createdTime: Date.now(), - isSeen: false, - sourceId: comment.id, - sourceType: 'comment', - sourceUpdateType: 'created', - sourceUserName: fromUser.name, - sourceUserUsername: fromUser.username, - sourceUserAvatarUrl: fromUser.avatarUrl, - sourceText: richTextToString(comment.content), - sourceSlug, - sourceTitle: `${group.name}`, - isSeenOnHref: sourceSlug, - } - await notificationRef.set(removeUndefinedProps(notification)) + // maybe TODO: send email notification to bet creator } export const createReferralNotification = async ( @@ -538,6 +587,14 @@ export const createReferralNotification = async ( referredByContract?: Contract, referredByGroup?: Group ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'you_referred_user' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -575,6 +632,8 @@ export const createReferralNotification = async ( : referredByContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) + + // TODO send email notification } export const createLoanIncomeNotification = async ( @@ -582,6 +641,14 @@ export const createLoanIncomeNotification = async ( idempotencyKey: string, income: number ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'loan_income' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -612,6 +679,14 @@ export const createChallengeAcceptedNotification = async ( acceptedAmount: number, contract: Contract ) => { + const privateUser = await getPrivateUser(challengeCreator.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'challenge_accepted' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${challengeCreator.id}/notifications`) .doc() @@ -645,6 +720,14 @@ export const createBettingStreakBonusNotification = async ( amount: number, idempotencyKey: string ) => { + const privateUser = await getPrivateUser(user.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'betting_streak_incremented' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${user.id}/notifications`) .doc(idempotencyKey) @@ -680,13 +763,24 @@ export const createLikeNotification = async ( contract: Contract, tip?: TipTxn ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'liked_and_tipped_your_contract' + ) + if (!sendToBrowser) return + + // not handling just likes, must include tip + if (!tip) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId: toUser.id, - reason: tip ? 'liked_and_tipped_your_contract' : 'liked_your_contract', + reason: 'liked_and_tipped_your_contract', createdTime: Date.now(), isSeen: false, sourceId: like.id, @@ -703,20 +797,8 @@ export const createLikeNotification = async ( sourceTitle: contract.question, } return await notificationRef.set(removeUndefinedProps(notification)) -} -export async function filterUserIdsForOnlyFollowerIds( - userIds: string[], - contractId: string -) { - // get contract follower documents and check here if they're a follower - const contractFollowersSnap = await firestore - .collection(`contracts/${contractId}/follows`) - .get() - const contractFollowersIds = contractFollowersSnap.docs.map( - (doc) => doc.data().id - ) - return userIds.filter((id) => contractFollowersIds.includes(id)) + // TODO send email notification } export const createUniqueBettorBonusNotification = async ( @@ -727,6 +809,15 @@ export const createUniqueBettorBonusNotification = async ( amount: number, idempotencyKey: string ) => { + console.log('createUniqueBettorBonusNotification') + const privateUser = await getPrivateUser(contractCreatorId) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'unique_bettors_on_your_contract' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${contractCreatorId}/notifications`) .doc(idempotencyKey) @@ -752,4 +843,6 @@ export const createUniqueBettorBonusNotification = async ( sourceContractCreatorUsername: contract.creatorUsername, } return await notificationRef.set(removeUndefinedProps(notification)) + + // TODO send email notification } diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index eabe0fd0..71272222 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,7 +1,11 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { PrivateUser, User } from '../../common/user' +import { + getDefaultNotificationSettings, + PrivateUser, + User, +} from '../../common/user' import { getUser, getUserByUsername, getValues } from './utils' import { randomString } from '../../common/util/random' import { @@ -79,6 +83,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { email, initialIpAddress: req.ip, initialDeviceToken: deviceToken, + notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid), } await firestore.collection('private-users').doc(auth.uid).create(privateUser) diff --git a/functions/src/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html index 6c75f026..c8f6a171 100644 --- a/functions/src/email-templates/500-mana.html +++ b/functions/src/email-templates/500-mana.html @@ -284,9 +284,12 @@ style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"> <div style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;"> - <p style="margin: 10px 0;">This e-mail has been sent to {{name}}, <a - href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;" - target="_blank">click here to unsubscribe</a>.</p> + <p style="margin: 10px 0;">This e-mail has been sent to {{name}}, + <a href="{{unsubscribeUrl}}" style=" + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications</a>. + </p> </div> </td> </tr> diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html index df215bdc..c73f7458 100644 --- a/functions/src/email-templates/creating-market.html +++ b/functions/src/email-templates/creating-market.html @@ -491,10 +491,10 @@ "> <p style="margin: 10px 0"> This e-mail has been sent to {{name}}, - <a href="{{unsubscribeLink}}" style=" + <a href="{{unsubscribeUrl}}" style=" color: inherit; text-decoration: none; - " target="_blank">click here to unsubscribe</a>. + " target="_blank">click here to manage your notifications</a>. </p> </div> </td> diff --git a/functions/src/email-templates/interesting-markets.html b/functions/src/email-templates/interesting-markets.html index d00b227e..7c3e653d 100644 --- a/functions/src/email-templates/interesting-markets.html +++ b/functions/src/email-templates/interesting-markets.html @@ -440,11 +440,10 @@ <p style="margin: 10px 0"> This e-mail has been sent to {{name}}, - <a href="{{unsubscribeLink}}" - style=" + <a href="{{unsubscribeUrl}}" style=" color: inherit; text-decoration: none; - " target="_blank">click here to unsubscribe</a> from future recommended markets. + " target="_blank">click here to manage your notifications</a>. </p> </div> </td> diff --git a/functions/src/email-templates/market-answer-comment.html b/functions/src/email-templates/market-answer-comment.html index 4e1a2bfa..a19aa7c3 100644 --- a/functions/src/email-templates/market-answer-comment.html +++ b/functions/src/email-templates/market-answer-comment.html @@ -526,19 +526,10 @@ " >our Discord</a >! Or, - <a - href="{{unsubscribeUrl}}" - style=" - font-family: 'Helvetica Neue', Helvetica, Arial, - sans-serif; - box-sizing: border-box; - font-size: 12px; - color: #999; - text-decoration: underline; - margin: 0; - " - >unsubscribe</a - >. + <a href="{{unsubscribeUrl}}" style=" + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications</a>. </td> </tr> </table> diff --git a/functions/src/email-templates/market-answer.html b/functions/src/email-templates/market-answer.html index 1f7fa5fa..b2d7f727 100644 --- a/functions/src/email-templates/market-answer.html +++ b/functions/src/email-templates/market-answer.html @@ -367,14 +367,9 @@ margin: 0; ">our Discord</a>! Or, <a href="{{unsubscribeUrl}}" style=" - font-family: 'Helvetica Neue', Helvetica, Arial, - sans-serif; - box-sizing: border-box; - font-size: 12px; - color: #999; - text-decoration: underline; - margin: 0; - ">unsubscribe</a>. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications</a>. </td> </tr> </table> diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index fa44c1d5..ee7976b0 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -485,14 +485,9 @@ margin: 0; ">our Discord</a>! Or, <a href="{{unsubscribeUrl}}" style=" - font-family: 'Helvetica Neue', Helvetica, Arial, - sans-serif; - box-sizing: border-box; - font-size: 12px; - color: #999; - text-decoration: underline; - margin: 0; - ">unsubscribe</a>. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications</a>. </td> </tr> </table> diff --git a/functions/src/email-templates/market-comment.html b/functions/src/email-templates/market-comment.html index 0b5b9a54..23e20dac 100644 --- a/functions/src/email-templates/market-comment.html +++ b/functions/src/email-templates/market-comment.html @@ -367,14 +367,9 @@ margin: 0; ">our Discord</a>! Or, <a href="{{unsubscribeUrl}}" style=" - font-family: 'Helvetica Neue', Helvetica, Arial, - sans-serif; - box-sizing: border-box; - font-size: 12px; - color: #999; - text-decoration: underline; - margin: 0; - ">unsubscribe</a>. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications</a>. </td> </tr> </table> diff --git a/functions/src/email-templates/market-resolved.html b/functions/src/email-templates/market-resolved.html index c1ff3beb..de29a0f1 100644 --- a/functions/src/email-templates/market-resolved.html +++ b/functions/src/email-templates/market-resolved.html @@ -500,14 +500,9 @@ margin: 0; ">our Discord</a>! Or, <a href="{{unsubscribeUrl}}" style=" - font-family: 'Helvetica Neue', Helvetica, Arial, - sans-serif; - box-sizing: border-box; - font-size: 12px; - color: #999; - text-decoration: underline; - margin: 0; - ">unsubscribe</a>. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications</a>. </td> </tr> </table> diff --git a/functions/src/email-templates/one-week.html b/functions/src/email-templates/one-week.html index 94889772..b8e233d5 100644 --- a/functions/src/email-templates/one-week.html +++ b/functions/src/email-templates/one-week.html @@ -1,519 +1,316 @@ <!DOCTYPE html> -<html - xmlns="http://www.w3.org/1999/xhtml" - xmlns:v="urn:schemas-microsoft-com:vml" - xmlns:o="urn:schemas-microsoft-com:office:office" -> - <head> - <title>7th Day Anniversary Gift! - - - - - - - - - + + + + - - + - - + + + + + +
+ +
+ + + + 🔥 - Daily betting streaks + Daily prediction streaks• What are they? You get {formatMoney(BETTING_STREAK_BONUS_AMOUNT)} more for each day - of consecutive betting up to {formatMoney(BETTING_STREAK_BONUS_MAX)} - . The more days you bet in a row, the more you earn! + of consecutive predicting up to{' '} + {formatMoney(BETTING_STREAK_BONUS_MAX)}. The more days you predict + in a row, the more you earn! • Where can I check my streak? diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 5fb9549e..f5d1c605 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -426,7 +426,7 @@ export function NewContract(props: {
Cost {!deservesFreeMarket ? ( diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 7ebc473b..1e8fbb4d 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -428,7 +428,7 @@ function IncomeNotificationItem(props: { reasonText = !simple ? `Bonus for ${ parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT - } new traders on` + } new predictors on` : 'bonus on' } else if (sourceType === 'tip') { reasonText = !simple ? `tipped you on` : `in tips on` @@ -436,7 +436,7 @@ function IncomeNotificationItem(props: { if (sourceText && +sourceText === 50) reasonText = '(max) for your' else reasonText = 'for your' } else if (sourceType === 'loan' && sourceText) { - reasonText = `of your invested bets returned as a` + reasonText = `of your invested predictions returned as a` // TODO: support just 'like' notification without a tip } else if (sourceType === 'tip_and_like' && sourceText) { reasonText = !simple ? `liked` : `in likes on` @@ -448,7 +448,9 @@ function IncomeNotificationItem(props: { : user?.currentBettingStreak ?? 0 const bettingStreakText = sourceType === 'betting_streak_bonus' && - (sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak') + (sourceText + ? `🔥 ${streakInDays} day Prediction Streak` + : 'Prediction Streak') return ( <> @@ -546,7 +548,7 @@ function IncomeNotificationItem(props: { {(isTip || isUniqueBettorBonus) && ( )} diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index 27c51c15..e81c239f 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -155,7 +155,7 @@ export default function TournamentPage(props: { sections: SectionInfo[] }) {
{sections.map( From c9d323c83ff4249d11d556b71e199fae32ce5376 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 13 Sep 2022 17:47:29 -0500 Subject: [PATCH 41/76] Small updates to experimental/home (#874) * Factor out section header * Remove daily balance change * Remove dead code * Layout, add streak * Fix visibility observer to work on server * Tweak * Formatting --- web/components/arrange-home.tsx | 2 +- web/components/bets-list.tsx | 5 +- web/components/visibility-observer.tsx | 12 ++-- web/hooks/use-contracts.ts | 37 +--------- web/pages/experimental/home/edit.tsx | 8 ++- web/pages/experimental/home/index.tsx | 99 ++++++++++++-------------- 6 files changed, 64 insertions(+), 99 deletions(-) diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index 646d30fe..6be187f8 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -111,9 +111,9 @@ export const getHomeItems = (groups: Group[], sections: string[]) => { if (!isArray(sections)) sections = [] const items = [ - { label: 'Daily movers', id: 'daily-movers' }, { label: 'Trending', id: 'score' }, { label: 'New for you', id: 'newest' }, + { label: 'Daily movers', id: 'daily-movers' }, ...groups.map((g) => ({ label: g.name, id: g.id, diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index ab232927..9c76174b 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -754,7 +754,10 @@ function SellButton(props: { ) } -function ProfitBadge(props: { profitPercent: number; className?: string }) { +export function ProfitBadge(props: { + profitPercent: number + className?: string +}) { const { profitPercent, className } = props if (!profitPercent) return null const colors = diff --git a/web/components/visibility-observer.tsx b/web/components/visibility-observer.tsx index aea2e41d..288d8f0e 100644 --- a/web/components/visibility-observer.tsx +++ b/web/components/visibility-observer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { useEvent } from '../hooks/use-event' export function VisibilityObserver(props: { @@ -8,18 +8,16 @@ export function VisibilityObserver(props: { const { className } = props const [elem, setElem] = useState(null) const onVisibilityUpdated = useEvent(props.onVisibilityUpdated) - const observer = useRef( - new IntersectionObserver(([entry]) => { - onVisibilityUpdated(entry.isIntersecting) - }, {}) - ).current useEffect(() => { if (elem) { + const observer = new IntersectionObserver(([entry]) => { + onVisibilityUpdated(entry.isIntersecting) + }, {}) observer.observe(elem) return () => observer.unobserve(elem) } - }, [elem, observer]) + }, [elem, onVisibilityUpdated]) return
} diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 2f3bea7b..1ea2f232 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -1,10 +1,8 @@ import { useFirestoreQueryData } from '@react-query-firebase/firestore' -import { isEqual } from 'lodash' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { Contract, listenForActiveContracts, - listenForContract, listenForContracts, listenForHotContracts, listenForInactiveContracts, @@ -62,39 +60,6 @@ export const useHotContracts = () => { return hotContracts } -export const useUpdatedContracts = (contracts: Contract[] | undefined) => { - const [__, triggerUpdate] = useState(0) - const contractDict = useRef<{ [id: string]: Contract }>({}) - - useEffect(() => { - if (contracts === undefined) return - - contractDict.current = Object.fromEntries(contracts.map((c) => [c.id, c])) - - const disposes = contracts.map((contract) => { - const { id } = contract - - return listenForContract(id, (contract) => { - const curr = contractDict.current[id] - if (!isEqual(curr, contract)) { - contractDict.current[id] = contract as Contract - triggerUpdate((n) => n + 1) - } - }) - }) - - triggerUpdate((n) => n + 1) - - return () => { - disposes.forEach((dispose) => dispose()) - } - }, [!!contracts]) - - return contracts && Object.keys(contractDict.current).length > 0 - ? contracts.map((c) => contractDict.current[c.id]) - : undefined -} - export const usePrefetchUserBetContracts = (userId: string) => { const queryClient = useQueryClient() return queryClient.prefetchQuery( diff --git a/web/pages/experimental/home/edit.tsx b/web/pages/experimental/home/edit.tsx index 2ed9d2dd..8c242a34 100644 --- a/web/pages/experimental/home/edit.tsx +++ b/web/pages/experimental/home/edit.tsx @@ -28,7 +28,7 @@ export default function Home() {
- + <Title text="Customize your home page" /> <DoneButton /> </Row> @@ -47,7 +47,11 @@ function DoneButton(props: { className?: string }) { return ( <SiteLink href="/experimental/home"> - <Button size="lg" color="blue" className={clsx(className, 'flex')}> + <Button + size="lg" + color="blue" + className={clsx(className, 'flex whitespace-nowrap')} + > Done </Button> </SiteLink> diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 08f502b6..f5734918 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -1,7 +1,7 @@ import React from 'react' import Router from 'next/router' import { - PencilIcon, + AdjustmentsIcon, PlusSmIcon, ArrowSmRightIcon, } from '@heroicons/react/solid' @@ -26,11 +26,12 @@ import { Row } from 'web/components/layout/row' import { ProbChangeTable } from 'web/components/contract/prob-change-table' import { groupPath } from 'web/lib/firebase/groups' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' -import { calculatePortfolioProfit } from 'common/calculate-metrics' import { formatMoney } from 'common/util/format' import { useProbChanges } from 'web/hooks/use-prob-changes' +import { ProfitBadge } from 'web/components/bets-list' +import { calculatePortfolioProfit } from 'common/calculate-metrics' -const Home = () => { +export default function Home() { const user = useUser() useTracking('view home') @@ -44,14 +45,14 @@ const Home = () => { return ( <Page> <Col className="pm:mx-10 gap-4 px-4 pb-12"> - <Row className={'w-full items-center justify-between'}> - <Title className="!mb-0" text="Home" /> - - <EditButton /> + <Row className={'mt-4 w-full items-start justify-between'}> + <Row className="items-end gap-4"> + <Title className="!mb-1 !mt-0" text="Home" /> + <EditButton /> + </Row> + <DailyProfitAndBalance className="" user={user} /> </Row> - <DailyProfitAndBalance userId={user?.id} /> - {sections.map((item) => { const { id } = item if (id === 'daily-movers') { @@ -97,17 +98,10 @@ function SearchSection(props: { followed?: boolean }) { const { label, user, sort, yourBets, followed } = props - const href = `/home?s=${sort}` return ( <Col> - <SiteLink className="mb-2 text-xl" href={href}> - {label}{' '} - <ArrowSmRightIcon - className="mb-0.5 inline h-6 w-6 text-gray-500" - aria-hidden="true" - /> - </SiteLink> + <SectionHeader label={label} href={`/home?s=${sort}`} /> <ContractSearch user={user} defaultSort={sort} @@ -134,13 +128,7 @@ function GroupSection(props: { return ( <Col> - <SiteLink className="mb-2 text-xl" href={groupPath(group.slug)}> - {group.name}{' '} - <ArrowSmRightIcon - className="mb-0.5 inline h-6 w-6 text-gray-500" - aria-hidden="true" - /> - </SiteLink> + <SectionHeader label={group.name} href={groupPath(group.slug)} /> <ContractSearch user={user} defaultSort={'score'} @@ -159,15 +147,25 @@ function DailyMoversSection(props: { userId: string | null | undefined }) { return ( <Col className="gap-2"> - <SiteLink className="text-xl" href={'/daily-movers'}> - Daily movers{' '} + <SectionHeader label="Daily movers" href="daily-movers" /> + <ProbChangeTable changes={changes} /> + </Col> + ) +} + +function SectionHeader(props: { label: string; href: string }) { + const { label, href } = props + + return ( + <Row className="mb-3 items-center justify-between"> + <SiteLink className="text-xl" href={href}> + {label}{' '} <ArrowSmRightIcon className="mb-0.5 inline h-6 w-6 text-gray-500" aria-hidden="true" /> </SiteLink> - <ProbChangeTable changes={changes} /> - </Col> + </Row> ) } @@ -176,45 +174,42 @@ function EditButton(props: { className?: string }) { return ( <SiteLink href="/experimental/home/edit"> - <Button size="lg" color="gray-white" className={clsx(className, 'flex')}> - <PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />{' '} - Edit + <Button size="sm" color="gray-white" className={clsx(className, 'flex')}> + <AdjustmentsIcon className={clsx('h-[24px] w-5')} aria-hidden="true" /> </Button> </SiteLink> ) } function DailyProfitAndBalance(props: { - userId: string | null | undefined + user: User | null | undefined className?: string }) { - const { userId, className } = props - const metrics = usePortfolioHistory(userId ?? '', 'daily') ?? [] + const { user, className } = props + const metrics = usePortfolioHistory(user?.id ?? '', 'daily') ?? [] const [first, last] = [metrics[0], metrics[metrics.length - 1]] if (first === undefined || last === undefined) return null const profit = calculatePortfolioProfit(last) - calculatePortfolioProfit(first) - - const balanceChange = last.balance - first.balance + const profitPercent = profit / first.investmentValue return ( - <div className={clsx(className, 'text-lg')}> - <span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}> - {profit >= 0 && '+'} - {formatMoney(profit)} - </span>{' '} - profit and{' '} - <span - className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')} - > - {balanceChange >= 0 && '+'} - {formatMoney(balanceChange)} - </span>{' '} - balance today - </div> + <Row className={'gap-4'}> + <Col> + <div className="text-gray-500">Daily profit</div> + <Row className={clsx(className, 'items-center text-lg')}> + <span>{formatMoney(profit)}</span>{' '} + <ProfitBadge profitPercent={profitPercent * 100} /> + </Row> + </Col> + <Col> + <div className="text-gray-500">Streak</div> + <Row className={clsx(className, 'items-center text-lg')}> + <span>🔥 {user?.currentBettingStreak ?? 0}</span> + </Row> + </Col> + </Row> ) } - -export default Home From df3d7b591dfa234b1102ddf0cde936a7c4d35186 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 13 Sep 2022 17:00:34 -0600 Subject: [PATCH 42/76] Componentize notification line setting, don't use useEffect --- web/components/notification-settings.tsx | 143 +++++++++++++---------- web/pages/notifications.tsx | 1 + 2 files changed, 85 insertions(+), 59 deletions(-) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index c45510ac..61e3b9d9 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -1,11 +1,10 @@ -import { usePrivateUser } from 'web/hooks/use-user' -import React, { ReactNode, useEffect, useState } from 'react' -import { LoadingIndicator } from 'web/components/loading-indicator' +import React, { memo, ReactNode, useEffect, useState } from 'react' import { Row } from 'web/components/layout/row' import clsx from 'clsx' import { notification_subscription_types, notification_destination_types, + PrivateUser, } from 'common/user' import { updatePrivateUser } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' @@ -23,21 +22,22 @@ import { UsersIcon, } from '@heroicons/react/outline' import { WatchMarketModal } from 'web/components/contract/watch-market-modal' -import { filterDefined } from 'common/util/array' import toast from 'react-hot-toast' import { SwitchSetting } from 'web/components/switch-setting' +import { uniq } from 'lodash' +import { + storageStore, + usePersistentState, +} from 'web/hooks/use-persistent-state' +import { safeLocalStorage } from 'web/lib/util/local' export function NotificationSettings(props: { navigateToSection: string | undefined + privateUser: PrivateUser }) { - const { navigateToSection } = props - const privateUser = usePrivateUser() + const { navigateToSection, privateUser } = props const [showWatchModal, setShowWatchModal] = useState(false) - if (!privateUser || !privateUser.notificationSubscriptionTypes) { - return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} /> - } - const emailsEnabled: Array<keyof notification_subscription_types> = [ 'all_comments_on_watched_markets', 'all_replies_to_my_comments_on_watched_markets', @@ -165,32 +165,29 @@ export function NotificationSettings(props: { }, } - const NotificationSettingLine = ( - description: string, - key: keyof notification_subscription_types, - value: notification_destination_types[] - ) => { - const previousInAppValue = value.includes('browser') - const previousEmailValue = value.includes('email') + function NotificationSettingLine(props: { + description: string + subscriptionTypeKey: keyof notification_subscription_types + destinations: notification_destination_types[] + }) { + const { description, subscriptionTypeKey, destinations } = props + const previousInAppValue = destinations.includes('browser') + const previousEmailValue = destinations.includes('email') const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue) const [emailEnabled, setEmailEnabled] = useState(previousEmailValue) const loading = 'Changing Notifications Settings' const success = 'Changed Notification Settings!' - const highlight = navigateToSection === key + const highlight = navigateToSection === subscriptionTypeKey - useEffect(() => { - if ( - inAppEnabled !== previousInAppValue || - emailEnabled !== previousEmailValue - ) { - toast.promise( + const changeSetting = (setting: 'browser' | 'email', newValue: boolean) => { + toast + .promise( updatePrivateUser(privateUser.id, { notificationSubscriptionTypes: { ...privateUser.notificationSubscriptionTypes, - [key]: filterDefined([ - inAppEnabled ? 'browser' : undefined, - emailEnabled ? 'email' : undefined, - ]), + [subscriptionTypeKey]: destinations.includes(setting) + ? destinations.filter((d) => d !== setting) + : uniq([...destinations, setting]), }, }), { @@ -199,14 +196,14 @@ export function NotificationSettings(props: { error: 'Error changing notification settings. Try again?', } ) - } - }, [ - inAppEnabled, - emailEnabled, - previousInAppValue, - previousEmailValue, - key, - ]) + .then(() => { + if (setting === 'browser') { + setInAppEnabled(newValue) + } else { + setEmailEnabled(newValue) + } + }) + } return ( <Row @@ -220,17 +217,17 @@ export function NotificationSettings(props: { <span>{description}</span> </Row> <Row className={'gap-4'}> - {!browserDisabled.includes(key) && ( + {!browserDisabled.includes(subscriptionTypeKey) && ( <SwitchSetting checked={inAppEnabled} - onChange={setInAppEnabled} + onChange={(newVal) => changeSetting('browser', newVal)} label={'Web'} /> )} - {emailsEnabled.includes(key) && ( + {emailsEnabled.includes(subscriptionTypeKey) && ( <SwitchSetting checked={emailEnabled} - onChange={setEmailEnabled} + onChange={(newVal) => changeSetting('email', newVal)} label={'Email'} /> )} @@ -246,12 +243,22 @@ export function NotificationSettings(props: { return privateUser.notificationSubscriptionTypes[key] ?? [] } - const Section = (icon: ReactNode, data: sectionData) => { + const Section = memo(function Section(props: { + icon: ReactNode + data: sectionData + }) { + const { icon, data } = props const { label, subscriptionTypeToDescription } = data const expand = navigateToSection && Object.keys(subscriptionTypeToDescription).includes(navigateToSection) - const [expanded, setExpanded] = useState(expand) + + // 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-' + label, + store: storageStore(safeLocalStorage()), + }) // Not working as the default value for expanded, so using a useEffect useEffect(() => { @@ -278,19 +285,19 @@ export function NotificationSettings(props: { )} </Row> <Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}> - {Object.entries(subscriptionTypeToDescription).map(([key, value]) => - NotificationSettingLine( - value, - key as keyof notification_subscription_types, - getUsersSavedPreference( + {Object.entries(subscriptionTypeToDescription).map(([key, value]) => ( + <NotificationSettingLine + subscriptionTypeKey={key as keyof notification_subscription_types} + destinations={getUsersSavedPreference( key as keyof notification_subscription_types - ) - ) - )} + )} + description={value} + /> + ))} </Col> </Col> ) - } + }) return ( <div className={'p-2'}> @@ -302,20 +309,38 @@ export function NotificationSettings(props: { onClick={() => setShowWatchModal(true)} /> </Row> - {Section(<ChatIcon className={'h-6 w-6'} />, comments)} - {Section(<LightBulbIcon className={'h-6 w-6'} />, answers)} - {Section(<TrendingUpIcon className={'h-6 w-6'} />, updates)} - {Section(<UserIcon className={'h-6 w-6'} />, yourMarkets)} + <Section icon={<ChatIcon className={'h-6 w-6'} />} data={comments} /> + <Section + icon={<TrendingUpIcon className={'h-6 w-6'} />} + data={updates} + /> + <Section + icon={<LightBulbIcon className={'h-6 w-6'} />} + data={answers} + /> + <Section icon={<UserIcon className={'h-6 w-6'} />} data={yourMarkets} /> <Row className={'gap-2 text-xl text-gray-700'}> <span>Balance Changes</span> </Row> - {Section(<CurrencyDollarIcon className={'h-6 w-6'} />, bonuses)} - {Section(<CashIcon className={'h-6 w-6'} />, otherBalances)} + <Section + icon={<CurrencyDollarIcon className={'h-6 w-6'} />} + data={bonuses} + /> + <Section + icon={<CashIcon className={'h-6 w-6'} />} + data={otherBalances} + /> <Row className={'gap-2 text-xl text-gray-700'}> <span>General</span> </Row> - {Section(<UsersIcon className={'h-6 w-6'} />, userInteractions)} - {Section(<InboxInIcon className={'h-6 w-6'} />, generalOther)} + <Section + icon={<UsersIcon className={'h-6 w-6'} />} + data={userInteractions} + /> + <Section + icon={<InboxInIcon className={'h-6 w-6'} />} + data={generalOther} + /> <WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} /> </Col> </div> diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 1e8fbb4d..fcac8601 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -112,6 +112,7 @@ export default function Notifications() { content: ( <NotificationSettings navigateToSection={navigateToSection} + privateUser={privateUser} /> ), }, From 74335f2b0171ddb21acd8c9f1f46a65754b3aaa2 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Tue, 13 Sep 2022 16:16:07 -0700 Subject: [PATCH 43/76] Adjust market modal styles (#875) * Refactor add market modals into one component * Adjust style: stickier search, scroll auto --- web/components/contract-search.tsx | 2 +- web/components/contract-select-modal.tsx | 102 ++++++++++++++++++++++ web/components/editor/market-modal.tsx | 88 +++---------------- web/pages/group/[...slugs]/index.tsx | 106 +++++------------------ 4 files changed, 137 insertions(+), 161 deletions(-) create mode 100644 web/components/contract-select-modal.tsx diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index e4b7f9cf..5bd69057 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -200,7 +200,7 @@ export function ContractSearch(props: { } return ( - <Col className="h-full"> + <Col> <ContractSearchControls className={headerClassName} defaultSort={defaultSort} diff --git a/web/components/contract-select-modal.tsx b/web/components/contract-select-modal.tsx new file mode 100644 index 00000000..9e23264a --- /dev/null +++ b/web/components/contract-select-modal.tsx @@ -0,0 +1,102 @@ +import { Contract } from 'common/contract' +import { useState } from 'react' +import { Button } from './button' +import { ContractSearch } from './contract-search' +import { Col } from './layout/col' +import { Modal } from './layout/modal' +import { Row } from './layout/row' +import { LoadingIndicator } from './loading-indicator' + +export function SelectMarketsModal(props: { + title: string + description?: React.ReactNode + open: boolean + setOpen: (open: boolean) => void + submitLabel: (length: number) => string + onSubmit: (contracts: Contract[]) => void | Promise<void> + contractSearchOptions?: Partial<Parameters<typeof ContractSearch>[0]> +}) { + const { + title, + description, + open, + setOpen, + submitLabel, + onSubmit, + contractSearchOptions, + } = props + + const [contracts, setContracts] = useState<Contract[]>([]) + const [loading, setLoading] = useState(false) + + async function addContract(contract: Contract) { + if (contracts.map((c) => c.id).includes(contract.id)) { + setContracts(contracts.filter((c) => c.id !== contract.id)) + } else setContracts([...contracts, contract]) + } + + async function onFinish() { + setLoading(true) + await onSubmit(contracts) + setLoading(false) + setOpen(false) + setContracts([]) + } + + return ( + <Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}> + <Col className="h-[85vh] w-full gap-4 rounded-md bg-white"> + <div className="p-8 pb-0"> + <Row> + <div className={'text-xl text-indigo-700'}>{title}</div> + + {!loading && ( + <Row className="grow justify-end gap-4"> + {contracts.length > 0 && ( + <Button onClick={onFinish} color="indigo"> + {submitLabel(contracts.length)} + </Button> + )} + <Button + onClick={() => { + if (contracts.length > 0) { + setContracts([]) + } else { + setOpen(false) + } + }} + color="gray" + > + {contracts.length > 0 ? 'Reset' : 'Cancel'} + </Button> + </Row> + )} + </Row> + {description} + </div> + + {loading && ( + <div className="w-full justify-center"> + <LoadingIndicator /> + </div> + )} + + <div className="overflow-y-auto sm:px-8"> + <ContractSearch + hideOrderSelector + onContractClick={addContract} + cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} + highlightOptions={{ + contractIds: contracts.map((c) => c.id), + highlightClassName: + '!bg-indigo-100 outline outline-2 outline-indigo-300', + }} + additionalFilter={{}} /* hide pills */ + headerClassName="bg-white" + {...contractSearchOptions} + /> + </div> + </Col> + </Modal> + ) +} diff --git a/web/components/editor/market-modal.tsx b/web/components/editor/market-modal.tsx index 31c437b1..1e2c1482 100644 --- a/web/components/editor/market-modal.tsx +++ b/web/components/editor/market-modal.tsx @@ -1,12 +1,6 @@ import { Editor } from '@tiptap/react' import { Contract } from 'common/contract' -import { useState } from 'react' -import { Button } from '../button' -import { ContractSearch } from '../contract-search' -import { Col } from '../layout/col' -import { Modal } from '../layout/modal' -import { Row } from '../layout/row' -import { LoadingIndicator } from '../loading-indicator' +import { SelectMarketsModal } from '../contract-select-modal' import { embedContractCode, embedContractGridCode } from '../share-embed-button' import { insertContent } from './utils' @@ -17,83 +11,23 @@ export function MarketModal(props: { }) { const { editor, open, setOpen } = props - const [contracts, setContracts] = useState<Contract[]>([]) - const [loading, setLoading] = useState(false) - - async function addContract(contract: Contract) { - if (contracts.map((c) => c.id).includes(contract.id)) { - setContracts(contracts.filter((c) => c.id !== contract.id)) - } else setContracts([...contracts, contract]) - } - - async function doneAddingContracts() { - setLoading(true) + function onSubmit(contracts: Contract[]) { if (contracts.length == 1) { insertContent(editor, embedContractCode(contracts[0])) } else if (contracts.length > 1) { insertContent(editor, embedContractGridCode(contracts)) } - setLoading(false) - setOpen(false) - setContracts([]) } return ( - <Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}> - <Col className="h-[85vh] w-full gap-4 rounded-md bg-white"> - <Row className="p-8 pb-0"> - <div className={'text-xl text-indigo-700'}>Embed a market</div> - - {!loading && ( - <Row className="grow justify-end gap-4"> - {contracts.length == 1 && ( - <Button onClick={doneAddingContracts} color={'indigo'}> - Embed 1 question - </Button> - )} - {contracts.length > 1 && ( - <Button onClick={doneAddingContracts} color={'indigo'}> - Embed grid of {contracts.length} question - {contracts.length > 1 && 's'} - </Button> - )} - <Button - onClick={() => { - if (contracts.length > 0) { - setContracts([]) - } else { - setOpen(false) - } - }} - color="gray" - > - {contracts.length > 0 ? 'Reset' : 'Cancel'} - </Button> - </Row> - )} - </Row> - - {loading && ( - <div className="w-full justify-center"> - <LoadingIndicator /> - </div> - )} - - <div className="overflow-y-scroll sm:px-8"> - <ContractSearch - hideOrderSelector - onContractClick={addContract} - cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} - highlightOptions={{ - contractIds: contracts.map((c) => c.id), - highlightClassName: - '!bg-indigo-100 outline outline-2 outline-indigo-300', - }} - additionalFilter={{}} /* hide pills */ - headerClassName="bg-white" - /> - </div> - </Col> - </Modal> + <SelectMarketsModal + title="Embed markets" + open={open} + setOpen={setOpen} + submitLabel={(len) => + len == 1 ? 'Embed 1 question' : `Embed grid of ${len} questions` + } + onSubmit={onSubmit} + /> ) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index f124e225..f1521b42 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -31,8 +31,6 @@ import { SEO } from 'web/components/SEO' import { Linkify } from 'web/components/linkify' import { fromPropz, usePropz } from 'web/hooks/use-propz' import { Tabs } from 'web/components/layout/tabs' -import { LoadingIndicator } from 'web/components/loading-indicator' -import { Modal } from 'web/components/layout/modal' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ContractSearch } from 'web/components/contract-search' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' @@ -51,6 +49,7 @@ import { Spacer } from 'web/components/layout/spacer' import { usePost } from 'web/hooks/use-post' import { useAdmin } from 'web/hooks/use-admin' import { track } from '@amplitude/analytics-browser' +import { SelectMarketsModal } from 'web/components/contract-select-modal' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -401,27 +400,12 @@ function GroupLeaderboard(props: { function AddContractButton(props: { group: Group; user: User }) { const { group, user } = props const [open, setOpen] = useState(false) - const [contracts, setContracts] = useState<Contract[]>([]) - const [loading, setLoading] = useState(false) const groupContractIds = useGroupContractIds(group.id) - async function addContractToCurrentGroup(contract: Contract) { - if (contracts.map((c) => c.id).includes(contract.id)) { - setContracts(contracts.filter((c) => c.id !== contract.id)) - } else setContracts([...contracts, contract]) - } - - async function doneAddingContracts() { - Promise.all( - contracts.map(async (contract) => { - setLoading(true) - await addContractToGroup(group, contract, user.id) - }) - ).then(() => { - setLoading(false) - setOpen(false) - setContracts([]) - }) + async function onSubmit(contracts: Contract[]) { + await Promise.all( + contracts.map((contract) => addContractToGroup(group, contract, user.id)) + ) } return ( @@ -437,71 +421,27 @@ function AddContractButton(props: { group: Group; user: User }) { </Button> </div> - <Modal + <SelectMarketsModal open={open} setOpen={setOpen} - className={'max-w-4xl sm:p-0'} - size={'xl'} - > - <Col - className={'min-h-screen w-full max-w-4xl gap-4 rounded-md bg-white'} - > - <Col className="p-8 pb-0"> - <div className={'text-xl text-indigo-700'}>Add markets</div> - - <div className={'text-md my-4 text-gray-600'}> - Add pre-existing markets to this group, or{' '} - <Link href={`/create?groupId=${group.id}`}> - <span className="cursor-pointer font-semibold underline"> - create a new one - </span> - </Link> - . - </div> - - {contracts.length > 0 && ( - <Col className={'w-full '}> - {!loading ? ( - <Row className={'justify-end gap-4'}> - <Button onClick={doneAddingContracts} color={'indigo'}> - Add {contracts.length} question - {contracts.length > 1 && 's'} - </Button> - <Button - onClick={() => { - setContracts([]) - }} - color={'gray'} - > - Cancel - </Button> - </Row> - ) : ( - <Row className={'justify-center'}> - <LoadingIndicator /> - </Row> - )} - </Col> - )} - </Col> - - <div className={'overflow-y-scroll sm:px-8'}> - <ContractSearch - user={user} - hideOrderSelector={true} - onContractClick={addContractToCurrentGroup} - cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} - additionalFilter={{ - excludeContractIds: groupContractIds, - }} - highlightOptions={{ - contractIds: contracts.map((c) => c.id), - highlightClassName: '!bg-indigo-100 border-indigo-100 border-2', - }} - /> + title="Add markets" + description={ + <div className={'text-md my-4 text-gray-600'}> + Add pre-existing markets to this group, or{' '} + <Link href={`/create?groupId=${group.id}`}> + <span className="cursor-pointer font-semibold underline"> + create a new one + </span> + </Link> + . </div> - </Col> - </Modal> + } + submitLabel={(len) => `Add ${len} question${len !== 1 ? 's' : ''}`} + onSubmit={onSubmit} + contractSearchOptions={{ + additionalFilter: { excludeContractIds: groupContractIds }, + }} + /> </> ) } From f6feacfbc9bf69c98345daa7df9c1566db9b8b8d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 13 Sep 2022 17:18:16 -0600 Subject: [PATCH 44/76] Fix lint and persistent storage key --- web/components/notification-settings.tsx | 26 +++++++++++++----------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 61e3b9d9..83ebf894 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -81,14 +81,14 @@ export function NotificationSettings(props: { 'thank_you_for_purchases', ] - type sectionData = { + type SectionData = { label: string subscriptionTypeToDescription: { [key in keyof Partial<notification_subscription_types>]: string } } - const comments: sectionData = { + const comments: SectionData = { label: 'New Comments', subscriptionTypeToDescription: { all_comments_on_watched_markets: 'All new comments', @@ -102,7 +102,7 @@ export function NotificationSettings(props: { }, } - const answers: sectionData = { + const answers: SectionData = { label: 'New Answers', subscriptionTypeToDescription: { all_answers_on_watched_markets: 'All new answers', @@ -111,7 +111,7 @@ export function NotificationSettings(props: { // answers_by_market_creator_on_watched_markets: 'By market creator', }, } - const updates: sectionData = { + const updates: SectionData = { label: 'Updates & Resolutions', subscriptionTypeToDescription: { market_updates_on_watched_markets: 'All creator updates', @@ -121,7 +121,7 @@ export function NotificationSettings(props: { // probability_updates_on_watched_markets: 'Probability updates', }, } - const yourMarkets: sectionData = { + const yourMarkets: SectionData = { label: 'Markets You Created', subscriptionTypeToDescription: { your_contract_closed: 'Your market has closed (and needs resolution)', @@ -131,7 +131,7 @@ export function NotificationSettings(props: { tips_on_your_markets: 'Likes on your markets', }, } - const bonuses: sectionData = { + const bonuses: SectionData = { label: 'Bonuses', subscriptionTypeToDescription: { betting_streaks: 'Prediction streak bonuses', @@ -139,7 +139,7 @@ export function NotificationSettings(props: { unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets', }, } - const otherBalances: sectionData = { + const otherBalances: SectionData = { label: 'Other', subscriptionTypeToDescription: { loan_income: 'Automatic loans from your profitable bets', @@ -147,7 +147,7 @@ export function NotificationSettings(props: { tips_on_your_comments: 'Tips on your comments', }, } - const userInteractions: sectionData = { + const userInteractions: SectionData = { label: 'Users', subscriptionTypeToDescription: { tagged_user: 'A user tagged you', @@ -155,7 +155,7 @@ export function NotificationSettings(props: { contract_from_followed_user: 'New markets created by users you follow', }, } - const generalOther: sectionData = { + const generalOther: SectionData = { label: 'Other', subscriptionTypeToDescription: { trending_markets: 'Weekly interesting markets', @@ -245,7 +245,7 @@ export function NotificationSettings(props: { const Section = memo(function Section(props: { icon: ReactNode - data: sectionData + data: SectionData }) { const { icon, data } = props const { label, subscriptionTypeToDescription } = data @@ -256,14 +256,16 @@ export function NotificationSettings(props: { // 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-' + label, + key: + 'NotificationsSettingsSection-' + + Object.keys(subscriptionTypeToDescription).join('-'), store: storageStore(safeLocalStorage()), }) // Not working as the default value for expanded, so using a useEffect useEffect(() => { if (expand) setExpanded(true) - }, [expand]) + }, [expand, setExpanded]) return ( <Col className={clsx('ml-2 gap-2')}> From 58ef43a8ecca396ebf9e4a57354eee678eb780f0 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 13 Sep 2022 21:11:53 -0500 Subject: [PATCH 45/76] 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() { <div className="text-xl">Play-money predictions</div> <Image - height={150} - width={150} - className="self-center" - src="/flappy-logo.gif" + height={125} + width={125} + className="my-4 self-center" + src="/welcome/manipurple.png" + alt="Manifold Markets gradient logo" /> <div className="mb-4 text-sm"> @@ -22,5 +23,5 @@ export function MarketIntroPanel() { <BetSignUpPrompt /> </Col> - ) + } From be851b83829f1f2bfc267bee3d9496cc2298fef3 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 13 Sep 2022 21:23:36 -0500 Subject: [PATCH 46/76] 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() { <BetSignUpPrompt /> </Col> - + ) } 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 47/76] 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 <sgrugett@gmail.com> Date: Wed, 14 Sep 2022 00:51:43 -0500 Subject: [PATCH 48/76] 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 ( <Row className="flex w-full gap-2 pt-3"> {isSelf ? ( From 1ebb505752bd4e8ccb5cf2de69d19b2a162eee8b Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 14 Sep 2022 01:13:53 -0700 Subject: [PATCH 49/76] 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 ( - <Row className="flex w-full gap-2 pt-3"> + <Row className="items-center gap-2 pt-3"> {isSelf ? ( <Avatar avatarUrl={user.avatarUrl} username={user.username} /> ) : bettor ? ( From 7144e57c93a6254c656d8b5574d685517ec07539 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 14 Sep 2022 01:33:59 -0700 Subject: [PATCH 50/76] 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<T extends Bet = Bet> = 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<NumericBet> = { 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<T extends Bet = Bet> = Omit<T, 'id' | 'userId'> +export type CandidateBet<T extends Bet = Bet> = 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<T extends Bet> = Omit<T, 'id' | 'userId'> +export type CandidateBet<T extends Bet> = 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<Answer> = removeUndefinedProps(update) + const betsSnap = await firestore + .collectionGroup('bets') + .where('userId', '==', user.id) + .get() + const betsUpdate: Partial<Bet> = 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: { <FeedBet contract={contract} bet={betsById[topBetId]} /> </div> <div className="mt-2 ml-2 text-sm text-gray-500"> - {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! + {topBettor} made {formatMoney(profitById[topBetId] || 0)}! </div> </> )} 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 ( <Row className="items-center gap-2 pt-3"> - {isSelf ? ( - <Avatar avatarUrl={user.avatarUrl} username={user.username} /> - ) : bettor ? ( - <Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} /> + {showUser ? ( + <Avatar avatarUrl={userAvatarUrl} username={userUsername} /> ) : ( <EmptyAvatar className="mx-1" /> )} <BetStatusText bet={bet} contract={contract} - isSelf={isSelf} - bettor={bettor} + hideUser={!showUser} className="flex-1" /> </Row> @@ -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 ( <div className={clsx('text-sm text-gray-500', className)}> - {bettor ? ( - <UserLink name={bettor.name} username={bettor.username} /> + {!hideUser ? ( + <UserLink name={bet.userName} username={bet.userUsername} /> ) : ( - <span>{isSelf ? 'You' : 'A trader'}</span> + <span>{self?.id === bet.userId ? 'You' : 'A trader'}</span> )}{' '} {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 ( <tr> {!isYou && ( <td> <Avatar size={'sm'} - avatarUrl={user?.avatarUrl} - username={user?.username} + avatarUrl={bet.userAvatarUrl} + username={bet.userUsername} /> </td> )} 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 51/76] 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 <phil.bladen@gmail.com> Co-authored-by: Marshall Polaris <marshall@pol.rs> --- 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<any> + color?: ColorType +}) { + const { children, onClick, color } = props + return ( + <Button + color={color} + size="lg" + onClick={onClick} + className="btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case" + > + {children} + </Button> + ) +} + +export function TwitchPanel() { + const user = useUser() + const privateUser = usePrivateUser() + + const twitchInfo = privateUser?.twitchInfo + const twitchName = privateUser?.twitchInfo?.twitchName + const twitchToken = privateUser?.twitchInfo?.controlToken + const twitchBotConnected = privateUser?.twitchInfo?.botEnabled + + const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> + + const copyOverlayLink = async () => { + copyToClipboard(`http://localhost:1000/overlay?t=${twitchToken}`) + toast.success('Overlay link copied!', { + icon: linkIcon, + }) + } + + const copyDockLink = async () => { + copyToClipboard(`http://localhost:1000/dock?t=${twitchToken}`) + toast.success('Dock link copied!', { + icon: linkIcon, + }) + } + + const updateBotConnected = (connected: boolean) => async () => { + if (user && twitchInfo) { + twitchInfo.botEnabled = connected + await updatePrivateUser(user.id, { twitchInfo }) + } + } + + const [twitchLoading, setTwitchLoading] = useState(false) + + const createLink = async () => { + if (!user || !privateUser) return + setTwitchLoading(true) + + const promise = linkTwitchAccountRedirect(user, privateUser) + track('link twitch from profile') + await promise + + setTwitchLoading(false) + } + + return ( + <> + <div> + <label className="label">Twitch</label> + + {!twitchName ? ( + <Row> + <Button + color="indigo" + onClick={createLink} + disabled={twitchLoading} + > + Link your Twitch account + </Button> + {twitchLoading && <LoadingIndicator className="ml-4" />} + </Row> + ) : ( + <Row> + <span className="mr-4 text-gray-500">Linked Twitch account</span>{' '} + {twitchName} + </Row> + )} + </div> + + {twitchToken && ( + <div> + <div className="flex w-full"> + <div + className={clsx( + 'flex grow gap-4', + twitchToken ? '' : 'tooltip tooltip-top' + )} + data-tip="You must link your Twitch account first" + > + <BouncyButton color="blue" onClick={copyOverlayLink}> + Copy overlay link + </BouncyButton> + <BouncyButton color="indigo" onClick={copyDockLink}> + Copy dock link + </BouncyButton> + {twitchBotConnected ? ( + <BouncyButton color="red" onClick={updateBotConnected(false)}> + Remove bot from your channel + </BouncyButton> + ) : ( + <BouncyButton color="green" onClick={updateBotConnected(true)}> + Add bot to your channel + </BouncyButton> + )} + </div> + </div> + </div> + )} + </> + ) +} diff --git a/web/lib/api/api-key.ts b/web/lib/api/api-key.ts new file mode 100644 index 00000000..1a8c84c1 --- /dev/null +++ b/web/lib/api/api-key.ts @@ -0,0 +1,9 @@ +import { updatePrivateUser } from '../firebase/users' + +export const generateNewApiKey = async (userId: string) => { + const newApiKey = crypto.randomUUID() + + return await updatePrivateUser(userId, { apiKey: newApiKey }) + .then(() => newApiKey) + .catch(() => undefined) +} diff --git a/web/lib/twitch/link-twitch-account.ts b/web/lib/twitch/link-twitch-account.ts new file mode 100644 index 00000000..36fb12b5 --- /dev/null +++ b/web/lib/twitch/link-twitch-account.ts @@ -0,0 +1,41 @@ +import { PrivateUser, User } from 'common/user' +import { generateNewApiKey } from '../api/api-key' + +const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately + +export async function initLinkTwitchAccount( + manifoldUserID: string, + manifoldUserAPIKey: string +): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> { + const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + manifoldID: manifoldUserID, + apiKey: manifoldUserAPIKey, + redirectURL: window.location.href, + }), + }) + const responseData = await response.json() + if (!response.ok) { + throw new Error(responseData.message) + } + const responseFetch = fetch( + `${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}` + ) + return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())] +} + +export async function linkTwitchAccountRedirect( + user: User, + privateUser: PrivateUser +) { + const apiKey = privateUser.apiKey ?? (await generateNewApiKey(user.id)) + if (!apiKey) throw new Error("Couldn't retrieve or create Manifold api key") + + const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey) + + window.location.href = twitchAuthURL +} diff --git a/web/pages/api/v0/twitch/save.ts b/web/pages/api/v0/twitch/save.ts new file mode 100644 index 00000000..775817e9 --- /dev/null +++ b/web/pages/api/v0/twitch/save.ts @@ -0,0 +1,23 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { + CORS_ORIGIN_MANIFOLD, + CORS_ORIGIN_LOCALHOST, +} from 'common/envs/constants' +import { applyCorsHeaders } from 'web/lib/api/cors' +import { fetchBackend, forwardResponse } from 'web/lib/api/proxy' + +export const config = { api: { bodyParser: true } } + +export default async function route(req: NextApiRequest, res: NextApiResponse) { + await applyCorsHeaders(req, res, { + origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], + methods: 'POST', + }) + try { + const backendRes = await fetchBackend(req, 'savetwitchcredentials') + await forwardResponse(res, backendRes) + } catch (err) { + console.error('Error talking to cloud function: ', err) + res.status(500).json({ message: 'Error communicating with backend.' }) + } +} diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index 240fe8fa..6b70b5d2 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -12,15 +12,13 @@ import { uploadImage } from 'web/lib/firebase/storage' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { User, PrivateUser } from 'common/user' -import { - getUserAndPrivateUser, - updateUser, - updatePrivateUser, -} from 'web/lib/firebase/users' +import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users' import { defaultBannerUrl } from 'web/components/user-page' import { SiteLink } from 'web/components/site-link' import Textarea from 'react-expanding-textarea' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' +import { generateNewApiKey } from 'web/lib/api/api-key' +import { TwitchPanel } from 'web/components/profile/twitch-panel' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { return { props: { auth: await getUserAndPrivateUser(creds.uid) } } @@ -96,11 +94,8 @@ export default function ProfilePage(props: { } const updateApiKey = async (e: React.MouseEvent) => { - const newApiKey = crypto.randomUUID() - setApiKey(newApiKey) - await updatePrivateUser(user.id, { apiKey: newApiKey }).catch(() => { - setApiKey(privateUser.apiKey || '') - }) + const newApiKey = await generateNewApiKey(user.id) + setApiKey(newApiKey ?? '') e.preventDefault() } @@ -242,6 +237,8 @@ export default function ProfilePage(props: { </button> </div> </div> + + <TwitchPanel /> </Col> </Col> </Page> diff --git a/web/pages/twitch.tsx b/web/pages/twitch.tsx new file mode 100644 index 00000000..7ca892e8 --- /dev/null +++ b/web/pages/twitch.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react' + +import { Page } from 'web/components/page' +import { Col } from 'web/components/layout/col' +import { ManifoldLogo } from 'web/components/nav/manifold-logo' +import { useSaveReferral } from 'web/hooks/use-save-referral' +import { SEO } from 'web/components/SEO' +import { Spacer } from 'web/components/layout/spacer' +import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users' +import { track } from 'web/lib/service/analytics' +import { Row } from 'web/components/layout/row' +import { Button } from 'web/components/button' +import { useTracking } from 'web/hooks/use-tracking' +import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' +import { usePrivateUser, useUser } from 'web/hooks/use-user' +import { LoadingIndicator } from 'web/components/loading-indicator' +import toast from 'react-hot-toast' + +export default function TwitchLandingPage() { + useSaveReferral() + useTracking('view twitch landing page') + + const user = useUser() + const privateUser = usePrivateUser() + const twitchUser = privateUser?.twitchInfo?.twitchName + + const callback = + user && privateUser + ? () => linkTwitchAccountRedirect(user, privateUser) + : async () => { + const result = await firebaseLogin() + + const userId = result.user.uid + const { user, privateUser } = await getUserAndPrivateUser(userId) + if (!user || !privateUser) return + + await linkTwitchAccountRedirect(user, privateUser) + } + + const [isLoading, setLoading] = useState(false) + + const getStarted = async () => { + try { + setLoading(true) + + const promise = callback() + track('twitch page button click') + await promise + } catch (e) { + console.error(e) + toast.error('Failed to sign up. Please try again later.') + setLoading(false) + } + } + + return ( + <Page> + <SEO + title="Manifold Markets on Twitch" + description="Get more out of Twitch with play-money betting markets." + /> + <div className="px-4 pt-2 md:mt-0 lg:hidden"> + <ManifoldLogo /> + </div> + <Col className="items-center"> + <Col className="max-w-3xl"> + <Col className="mb-6 rounded-xl sm:m-12 sm:mt-0"> + <Row className="self-center"> + <img height={200} width={200} src="/twitch-logo.png" /> + <img height={200} width={200} src="/flappy-logo.gif" /> + </Row> + <div className="m-4 max-w-[550px] self-center"> + <h1 className="text-3xl sm:text-6xl xl:text-6xl"> + <div className="font-semibold sm:mb-2"> + <span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent"> + Bet + </span>{' '} + on your favorite streams + </div> + </h1> + <Spacer h={6} /> + <div className="mb-4 px-2 "> + Get more out of Twitch with play-money betting markets.{' '} + {!twitchUser && + 'Click the button below to link your Twitch account.'} + <br /> + </div> + </div> + + <Spacer h={6} /> + + {twitchUser ? ( + <div className="mt-3 self-center rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 "> + <div className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6"> + <div className="truncate text-sm font-medium text-gray-500"> + Twitch account linked + </div> + <div className="mt-1 text-2xl font-semibold text-gray-900"> + {twitchUser} + </div> + </div> + </div> + ) : isLoading ? ( + <LoadingIndicator spinnerClassName="!w-16 !h-16" /> + ) : ( + <Button + size="2xl" + color="gradient" + className="self-center" + onClick={getStarted} + > + Get started + </Button> + )} + </Col> + </Col> + </Col> + </Page> + ) +} diff --git a/web/public/twitch-logo.png b/web/public/twitch-logo.png new file mode 100644 index 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 <iansphilips@gmail.com> Date: Wed, 14 Sep 2022 07:27:20 -0600 Subject: [PATCH 52/76] 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 <iansphilips@gmail.com> Date: Wed, 14 Sep 2022 08:56:05 -0600 Subject: [PATCH 53/76] 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 ( <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={clsx( + 'text-8xl', + missingStreak ? 'grayscale' : 'grayscale-0' + )} + > + 🔥 + </span> + {missingStreak && ( + <Col className={' gap-2 text-center'}> + <span className={'font-bold'}> + You haven't predicted yet today! + </span> + <span className={'ml-2'}> + 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! + </span> + </Col> + )} <span className="text-xl">Daily prediction streaks</span> <Col className={'gap-2'}> <span className={'text-indigo-700'}>• What are they?</span> @@ -37,3 +62,17 @@ export function BettingStreakModal(props: { </Modal> ) } + +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 }) { <BettingStreakModal isOpen={showBettingStreakModal} setOpen={setShowBettingStreakModal} + currentUser={currentUser} /> {showLoansModal && ( <LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} /> @@ -139,7 +143,12 @@ export function UserPage(props: { user: User }) { <span>profit</span> </Col> <Col - className={'cursor-pointer items-center text-gray-500'} + className={clsx( + 'cursor-pointer items-center text-gray-500', + isCurrentUser && !hasCompletedStreakToday(user) + ? 'grayscale' + : 'grayscale-0' + )} onClick={() => setShowBettingStreakModal(true)} > <span>🔥 {user.currentBettingStreak ?? 0}</span> From 7ba2eab65ea927ba183a0ba6948a10649390cab2 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 14 Sep 2022 10:26:08 -0600 Subject: [PATCH 54/76] 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 <iansphilips@gmail.com> Date: Wed, 14 Sep 2022 10:29:48 -0600 Subject: [PATCH 55/76] 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 <abc.sinclair@gmail.com> Date: Wed, 14 Sep 2022 13:19:12 -0700 Subject: [PATCH 56/76] 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 ( - <NodeViewWrapper className="tiptap-tweet"> + <NodeViewWrapper className="tiptap-tweet [&_.twitter-tweet]:mx-auto"> <TwitterTweetEmbed tweetId={tweetId} /> </NodeViewWrapper> ) From 68b0539fc1ca5399550b3305742c43fb97c8d229 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Wed, 14 Sep 2022 15:06:11 -0700 Subject: [PATCH 57/76] 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 <iansphilips@gmail.com> Date: Wed, 14 Sep 2022 17:17:32 -0600 Subject: [PATCH 58/76] 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<notification_reason_types, keyof notification_subscription_types> -> = { - 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<notification_reason_types, notification_preference> +> = { + 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 @@ -<!DOCTYPE html> -<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" - xmlns:o="urn:schemas-microsoft-com:office:office"> - -<head> - <title>Manifold Markets 7th Day Anniversary Gift! - - - - - - - - - - - - - - - -
- -
-
+ +
+ + + + - - -
+ - - - - - - - - - - - - + + +
- - - - - - -
- -
-
-
-

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

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

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

-

-
-

- Cheers, -

-

- David from Manifold -

-

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

+ Hi {{name}},

+
+
+
+

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

+
+
+

+
+ + + + +
+ + + + +
+ + Claim M$500 + +
+
+
+
+

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

+ +

 

+

Cheers, +

+

David + from Manifold

+

 

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

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

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

+

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

diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 2c9c6f12..b9d34363 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,10 +1,12 @@ import { DOMAIN } from '../../common/envs/constants' -import { Answer } from '../../common/answer' import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' -import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' -import { PrivateUser, User } from '../../common/user' +import { + notification_subscription_types, + PrivateUser, + User, +} from '../../common/user' import { formatLargeNumber, formatMoney, @@ -14,15 +16,16 @@ import { getValueFromBucket } from '../../common/calculate-dpm' import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail, sendTextEmail } from './send-email' -import { getPrivateUser, getUser } from './utils' -import { getFunctionUrl } from '../../common/api' -import { richTextToString } from '../../common/util/parse' +import { getUser } from './utils' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' - -const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') +import { + notification_reason_types, + getDestinationsForUser, +} from '../../common/notification' export const sendMarketResolutionEmail = async ( - userId: string, + reason: notification_reason_types, + privateUser: PrivateUser, investment: number, payout: number, creator: User, @@ -32,15 +35,11 @@ export const sendMarketResolutionEmail = async ( resolutionProbability?: number, resolutions?: { [outcome: string]: number } ) => { - const privateUser = await getPrivateUser(userId) - if ( - !privateUser || - privateUser.unsubscribedFromResolutionEmails || - !privateUser.email - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser || !privateUser.email || !sendToEmail) return - const user = await getUser(userId) + const user = await getUser(privateUser.id) if (!user) return const outcome = toDisplayResolution( @@ -53,13 +52,10 @@ export const sendMarketResolutionEmail = async ( const subject = `Resolved ${outcome}: ${contract.question}` const creatorPayoutText = - creatorPayout >= 1 && userId === creator.id + creatorPayout >= 1 && privateUser.id === creator.id ? ` (plus ${formatMoney(creatorPayout)} in commissions)` : '' - const emailType = 'market-resolved' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - const displayedInvestment = Number.isNaN(investment) || investment < 0 ? formatMoney(0) @@ -154,11 +150,12 @@ export const sendWelcomeEmail = async ( ) => { if (!privateUser || !privateUser.email) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'onboarding_flow' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, @@ -166,7 +163,7 @@ export const sendWelcomeEmail = async ( 'welcome', { name: firstName, - unsubscribeLink, + unsubscribeUrl, }, { from: 'David from Manifold ', @@ -217,23 +214,23 @@ export const sendOneWeekBonusEmail = async ( if ( !privateUser || !privateUser.email || - privateUser.unsubscribedFromGenericEmails + !privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email') ) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'onboarding_flow' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, 'Manifold Markets one week anniversary gift', 'one-week', { name: firstName, - unsubscribeLink, + unsubscribeUrl, manalink: 'https://manifold.markets/link/lj4JbBvE', }, { @@ -250,23 +247,23 @@ export const sendCreatorGuideEmail = async ( if ( !privateUser || !privateUser.email || - privateUser.unsubscribedFromGenericEmails + !privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email') ) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'onboarding_flow' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, 'Create your own prediction market', 'creating-market', { name: firstName, - unsubscribeLink, + unsubscribeUrl, }, { from: 'David from Manifold ', @@ -282,15 +279,18 @@ export const sendThankYouEmail = async ( if ( !privateUser || !privateUser.email || - privateUser.unsubscribedFromGenericEmails + !privateUser.notificationSubscriptionTypes.thank_you_for_purchases.includes( + 'email' + ) ) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'thank_you_for_purchases' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, @@ -298,7 +298,7 @@ export const sendThankYouEmail = async ( 'thank-you', { name: firstName, - unsubscribeLink, + unsubscribeUrl, }, { from: 'David from Manifold ', @@ -307,16 +307,15 @@ export const sendThankYouEmail = async ( } export const sendMarketCloseEmail = async ( + reason: notification_reason_types, user: User, privateUser: PrivateUser, contract: Contract ) => { - if ( - !privateUser || - privateUser.unsubscribedFromResolutionEmails || - !privateUser.email - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + + if (!privateUser.email || !sendToEmail) return const { username, name, id: userId } = user const firstName = name.split(' ')[0] @@ -324,8 +323,6 @@ export const sendMarketCloseEmail = async ( const { question, slug, volume } = contract const url = `https://${DOMAIN}/${username}/${slug}` - const emailType = 'market-resolve' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` return await sendTemplateEmail( privateUser.email, @@ -343,30 +340,24 @@ export const sendMarketCloseEmail = async ( } export const sendNewCommentEmail = async ( - userId: string, + reason: notification_reason_types, + privateUser: PrivateUser, commentCreator: User, contract: Contract, - comment: Comment, + commentText: string, + commentId: string, bet?: Bet, answerText?: string, answerId?: string ) => { - const privateUser = await getPrivateUser(userId) - if ( - !privateUser || - !privateUser.email || - privateUser.unsubscribedFromCommentEmails - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser || !privateUser.email || !sendToEmail) return - const { question, creatorUsername, slug } = contract - const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}` - const emailType = 'market-comment' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const { question } = contract + const marketUrl = `https://${DOMAIN}/${contract.creatorUsername}/${contract.slug}#${commentId}` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator - const { content } = comment - const text = richTextToString(content) let betDescription = '' if (bet) { @@ -380,7 +371,7 @@ export const sendNewCommentEmail = async ( const from = `${commentorName} on Manifold ` if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { - const answerNumber = `#${answerId}` + const answerNumber = answerId ? `#${answerId}` : '' return await sendTemplateEmail( privateUser.email, @@ -391,7 +382,7 @@ export const sendNewCommentEmail = async ( answerNumber, commentorName, commentorAvatarUrl: commentorAvatarUrl ?? '', - comment: text, + comment: commentText, marketUrl, unsubscribeUrl, betDescription, @@ -412,7 +403,7 @@ export const sendNewCommentEmail = async ( { commentorName, commentorAvatarUrl: commentorAvatarUrl ?? '', - comment: text, + comment: commentText, marketUrl, unsubscribeUrl, betDescription, @@ -423,29 +414,24 @@ export const sendNewCommentEmail = async ( } export const sendNewAnswerEmail = async ( - answer: Answer, - contract: Contract + reason: notification_reason_types, + privateUser: PrivateUser, + name: string, + text: string, + contract: Contract, + avatarUrl?: string ) => { - // Send to just the creator for now. - const { creatorId: userId } = contract - + const { creatorId } = contract // Don't send the creator's own answers. - if (answer.userId === userId) return + if (privateUser.id === creatorId) return - const privateUser = await getPrivateUser(userId) - if ( - !privateUser || - !privateUser.email || - privateUser.unsubscribedFromAnswerEmails - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser.email || !sendToEmail) return const { question, creatorUsername, slug } = contract - const { name, avatarUrl, text } = answer const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}` - const emailType = 'market-answer' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const subject = `New answer on ${question}` const from = `${name} ` @@ -474,12 +460,15 @@ export const sendInterestingMarketsEmail = async ( if ( !privateUser || !privateUser.email || - privateUser?.unsubscribedFromWeeklyTrendingEmails + !privateUser.notificationSubscriptionTypes.trending_markets.includes( + 'email' + ) ) return - const emailType = 'weekly-trending' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${privateUser.id}&type=${emailType}` + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'trending_markets' as keyof notification_subscription_types + }` const { name } = user const firstName = name.split(' ')[0] @@ -490,7 +479,7 @@ export const sendInterestingMarketsEmail = async ( 'interesting-markets', { name: firstName, - unsubscribeLink: unsubscribeUrl, + unsubscribeUrl, question1Title: contractsToSend[0].question, question1Link: contractUrl(contractsToSend[0]), diff --git a/functions/src/market-close-notifications.ts b/functions/src/market-close-notifications.ts index f31674a1..7878e410 100644 --- a/functions/src/market-close-notifications.ts +++ b/functions/src/market-close-notifications.ts @@ -3,7 +3,6 @@ import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' import { getPrivateUser, getUserByUsername } from './utils' -import { sendMarketCloseEmail } from './emails' import { createNotification } from './create-notification' export const marketCloseNotifications = functions @@ -56,7 +55,6 @@ async function sendMarketCloseEmails() { const privateUser = await getPrivateUser(user.id) if (!privateUser) continue - await sendMarketCloseEmail(user, privateUser, contract) await createNotification( contract.id, 'contract', diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index a36a8bca..a46420bc 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -1,15 +1,11 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { compact, uniq } from 'lodash' +import { compact } from 'lodash' import { getContract, getUser, getValues } from './utils' import { ContractComment } from '../../common/comment' -import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' -import { - createCommentOrAnswerOrUpdatedContractNotification, - filterUserIdsForOnlyFollowerIds, -} from './create-notification' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { parseMentions, richTextToString } from '../../common/util/parse' import { addUserToContractFollowers } from './follow-market' @@ -77,10 +73,10 @@ export const onCreateCommentOnContract = functions const comments = await getValues( firestore.collection('contracts').doc(contractId).collection('comments') ) - const relatedSourceType = comment.replyToCommentId - ? 'comment' - : comment.answerOutcome + const repliedToType = answer ? 'answer' + : comment.replyToCommentId + ? 'comment' : undefined const repliedUserId = comment.replyToCommentId @@ -96,31 +92,11 @@ export const onCreateCommentOnContract = functions richTextToString(comment.content), contract, { - relatedSourceType, + repliedToType, + repliedToId: comment.replyToCommentId || answer?.id, + repliedToContent: answer ? answer.text : undefined, repliedUserId, taggedUserIds: compact(parseMentions(comment.content)), } ) - - const recipientUserIds = await filterUserIdsForOnlyFollowerIds( - uniq([ - contract.creatorId, - ...comments.map((comment) => comment.userId), - ]).filter((id) => id !== comment.userId), - contractId - ) - - await Promise.all( - recipientUserIds.map((userId) => - sendNewCommentEmail( - userId, - commentCreator, - contract, - comment, - bet, - answer?.text, - answer?.id - ) - ) - ) }) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index d7ecd56e..2972a305 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -13,32 +13,7 @@ export const onUpdateContract = functions.firestore if (!contractUpdater) throw new Error('Could not find contract updater') const previousValue = change.before.data() as Contract - if (previousValue.isResolved !== contract.isResolved) { - let resolutionText = contract.resolution ?? contract.question - if (contract.outcomeType === 'FREE_RESPONSE') { - const answerText = contract.answers.find( - (answer) => answer.id === contract.resolution - )?.text - if (answerText) resolutionText = answerText - } else if (contract.outcomeType === 'BINARY') { - if (resolutionText === 'MKT' && contract.resolutionProbability) - resolutionText = `${contract.resolutionProbability}%` - else if (resolutionText === 'MKT') resolutionText = 'PROB' - } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { - if (resolutionText === 'MKT' && contract.resolutionValue) - resolutionText = `${contract.resolutionValue}` - } - - await createCommentOrAnswerOrUpdatedContractNotification( - contract.id, - 'contract', - 'resolved', - contractUpdater, - eventId, - resolutionText, - contract - ) - } else if ( + if ( previousValue.closeTime !== contract.closeTime || previousValue.question !== contract.question ) { diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 6f8ea2e9..015ac72f 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -1,6 +1,6 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { difference, mapValues, groupBy, sumBy } from 'lodash' +import { mapValues, groupBy, sumBy } from 'lodash' import { Contract, @@ -8,10 +8,8 @@ import { MultipleChoiceContract, RESOLUTIONS, } from '../../common/contract' -import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, isProd, payUser } from './utils' -import { sendMarketResolutionEmail } from './emails' import { getLoanPayouts, getPayouts, @@ -23,7 +21,7 @@ import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' import { APIError, newEndpoint, validate } from './api' import { getContractBetMetrics } from '../../common/calculate' -import { floatingEqual } from '../../common/util/math' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' const bodySchema = z.object({ contractId: z.string(), @@ -163,15 +161,45 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) - await sendResolutionEmails( - bets, - userPayoutsWithoutLoans, + const userInvestments = mapValues( + groupBy(bets, (bet) => bet.userId), + (bets) => getContractBetMetrics(contract, bets).invested + ) + let resolutionText = outcome ?? contract.question + if (contract.outcomeType === 'FREE_RESPONSE') { + const answerText = contract.answers.find( + (answer) => answer.id === outcome + )?.text + if (answerText) resolutionText = answerText + } else if (contract.outcomeType === 'BINARY') { + if (resolutionText === 'MKT' && probabilityInt) + resolutionText = `${probabilityInt}%` + else if (resolutionText === 'MKT') resolutionText = 'PROB' + } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { + if (resolutionText === 'MKT' && value) resolutionText = `${value}` + } + + // TODO: this actually may be too slow to complete with a ton of users to notify? + await createCommentOrAnswerOrUpdatedContractNotification( + contract.id, + 'contract', + 'resolved', creator, - creatorPayout, + contract.id + '-resolution', + resolutionText, contract, - outcome, - resolutionProbability, - resolutions + undefined, + { + bets, + userInvestments, + userPayouts: userPayoutsWithoutLoans, + creator, + creatorPayout, + contract, + outcome, + resolutionProbability, + resolutions, + } ) return updatedContract @@ -189,51 +217,6 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => { .then(() => ({ status: 'success' })) } -const sendResolutionEmails = async ( - bets: Bet[], - userPayouts: { [userId: string]: number }, - creator: User, - creatorPayout: number, - contract: Contract, - outcome: string, - resolutionProbability?: number, - resolutions?: { [outcome: string]: number } -) => { - const investedByUser = mapValues( - groupBy(bets, (bet) => bet.userId), - (bets) => getContractBetMetrics(contract, bets).invested - ) - const investedUsers = Object.keys(investedByUser).filter( - (userId) => !floatingEqual(investedByUser[userId], 0) - ) - - const nonWinners = difference(investedUsers, Object.keys(userPayouts)) - const emailPayouts = [ - ...Object.entries(userPayouts), - ...nonWinners.map((userId) => [userId, 0] as const), - ].map(([userId, payout]) => ({ - userId, - investment: investedByUser[userId] ?? 0, - payout, - })) - - await Promise.all( - emailPayouts.map(({ userId, investment, payout }) => - sendMarketResolutionEmail( - userId, - investment, - payout, - creator, - creatorPayout, - contract, - outcome, - resolutionProbability, - resolutions - ) - ) - ) -} - function getResolutionParams(contract: Contract, body: string) { const { outcomeType } = contract diff --git a/functions/src/scripts/create-new-notification-preferences.ts b/functions/src/scripts/create-new-notification-preferences.ts new file mode 100644 index 00000000..a6bd1a0b --- /dev/null +++ b/functions/src/scripts/create-new-notification-preferences.ts @@ -0,0 +1,30 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getDefaultNotificationSettings } from 'common/user' +import { getAllPrivateUsers, isProd } from 'functions/src/utils' +initAdmin() + +const firestore = admin.firestore() + +async function main() { + const privateUsers = await getAllPrivateUsers() + const disableEmails = !isProd() + await Promise.all( + privateUsers.map((privateUser) => { + if (!privateUser.id) return Promise.resolve() + return firestore + .collection('private-users') + .doc(privateUser.id) + .update({ + notificationSubscriptionTypes: getDefaultNotificationSettings( + privateUser.id, + privateUser, + disableEmails + ), + }) + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) diff --git a/functions/src/scripts/create-private-users.ts b/functions/src/scripts/create-private-users.ts index acce446e..f9b8c3a1 100644 --- a/functions/src/scripts/create-private-users.ts +++ b/functions/src/scripts/create-private-users.ts @@ -3,7 +3,7 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' initAdmin() -import { PrivateUser, User } from 'common/user' +import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user' import { STARTING_BALANCE } from 'common/economy' const firestore = admin.firestore() @@ -21,6 +21,7 @@ async function main() { id: user.id, email, username, + notificationSubscriptionTypes: getDefaultNotificationSettings(user.id), } if (user.totalDeposits === undefined) { diff --git a/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx deleted file mode 100644 index 7ee27fb5..00000000 --- a/web/components/NotificationSettings.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { useUser } from 'web/hooks/use-user' -import React, { useEffect, useState } from 'react' -import { notification_subscribe_types, PrivateUser } from 'common/user' -import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users' -import toast from 'react-hot-toast' -import { track } from '@amplitude/analytics-browser' -import { LoadingIndicator } from 'web/components/loading-indicator' -import { Row } from 'web/components/layout/row' -import clsx from 'clsx' -import { CheckIcon, XIcon } from '@heroicons/react/outline' -import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { Col } from 'web/components/layout/col' -import { FollowMarketModal } from 'web/components/contract/follow-market-modal' - -export function NotificationSettings() { - const user = useUser() - const [notificationSettings, setNotificationSettings] = - useState('all') - const [emailNotificationSettings, setEmailNotificationSettings] = - useState('all') - const [privateUser, setPrivateUser] = useState(null) - const [showModal, setShowModal] = useState(false) - - useEffect(() => { - if (user) listenForPrivateUser(user.id, setPrivateUser) - }, [user]) - - useEffect(() => { - if (!privateUser) return - if (privateUser.notificationPreferences) { - setNotificationSettings(privateUser.notificationPreferences) - } - if ( - privateUser.unsubscribedFromResolutionEmails && - privateUser.unsubscribedFromCommentEmails && - privateUser.unsubscribedFromAnswerEmails - ) { - setEmailNotificationSettings('none') - } else if ( - !privateUser.unsubscribedFromResolutionEmails && - !privateUser.unsubscribedFromCommentEmails && - !privateUser.unsubscribedFromAnswerEmails - ) { - setEmailNotificationSettings('all') - } else { - setEmailNotificationSettings('less') - } - }, [privateUser]) - - const loading = 'Changing Notifications Settings' - const success = 'Notification Settings Changed!' - function changeEmailNotifications(newValue: notification_subscribe_types) { - if (!privateUser) return - if (newValue === 'all') { - toast.promise( - updatePrivateUser(privateUser.id, { - unsubscribedFromResolutionEmails: false, - unsubscribedFromCommentEmails: false, - unsubscribedFromAnswerEmails: false, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } else if (newValue === 'less') { - toast.promise( - updatePrivateUser(privateUser.id, { - unsubscribedFromResolutionEmails: false, - unsubscribedFromCommentEmails: true, - unsubscribedFromAnswerEmails: true, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } else if (newValue === 'none') { - toast.promise( - updatePrivateUser(privateUser.id, { - unsubscribedFromResolutionEmails: true, - unsubscribedFromCommentEmails: true, - unsubscribedFromAnswerEmails: true, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } - } - - function changeInAppNotificationSettings( - newValue: notification_subscribe_types - ) { - if (!privateUser) return - track('In-App Notification Preferences Changed', { - newPreference: newValue, - oldPreference: privateUser.notificationPreferences, - }) - toast.promise( - updatePrivateUser(privateUser.id, { - notificationPreferences: newValue, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } - - useEffect(() => { - if (privateUser && privateUser.notificationPreferences) - setNotificationSettings(privateUser.notificationPreferences) - else setNotificationSettings('all') - }, [privateUser]) - - if (!privateUser) { - return - } - - function NotificationSettingLine(props: { - label: string | React.ReactNode - highlight: boolean - onClick?: () => void - }) { - const { label, highlight, onClick } = props - return ( - - {highlight ? : } - {label} - - ) - } - - return ( -
-
In App Notifications
- - changeInAppNotificationSettings( - choice as notification_subscribe_types - ) - } - className={'col-span-4 p-2'} - toggleClassName={'w-24'} - /> -
-
- - You will receive notifications for these general events: - - - - You will receive new comment, answer, & resolution notifications on - questions: - - - That you watch - you - auto-watch questions if: - - } - onClick={() => setShowModal(true)} - /> - - • You create it - • You bet, comment on, or answer it - • You add liquidity to it - - • If you select 'Less' and you've commented on or answered a - question, you'll only receive notification on direct replies to - your comments or answers - - - - -
Email Notifications
- - changeEmailNotifications(choice as notification_subscribe_types) - } - className={'col-span-4 p-2'} - toggleClassName={'w-24'} - /> -
-
- You will receive emails for: - - - - -
-
- - - ) -} diff --git a/web/components/contract/follow-market-modal.tsx b/web/components/contract/watch-market-modal.tsx similarity index 74% rename from web/components/contract/follow-market-modal.tsx rename to web/components/contract/watch-market-modal.tsx index fb62ce9f..2fb9bc00 100644 --- a/web/components/contract/follow-market-modal.tsx +++ b/web/components/contract/watch-market-modal.tsx @@ -4,7 +4,7 @@ import { EyeIcon } from '@heroicons/react/outline' import React from 'react' import clsx from 'clsx' -export const FollowMarketModal = (props: { +export const WatchMarketModal = (props: { open: boolean setOpen: (b: boolean) => void title?: string @@ -18,20 +18,21 @@ export const FollowMarketModal = (props: {
• What is watching? - You can receive notifications on questions you're interested in by + You'll receive notifications on markets by betting, commenting, or clicking the • What types of notifications will I receive? - You'll receive in-app notifications for new comments, answers, and - updates to the question. + You'll receive notifications for new comments, answers, and updates + to the question. See the notifications settings pages to customize + which types of notifications you receive on watched markets. diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 332b044a..1dd261cb 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -11,7 +11,7 @@ import { User } from 'common/user' import { useContractFollows } from 'web/hooks/use-follows' import { firebaseLogin, updateUser } from 'web/lib/firebase/users' import { track } from 'web/lib/service/analytics' -import { FollowMarketModal } from 'web/components/contract/follow-market-modal' +import { WatchMarketModal } from 'web/components/contract/watch-market-modal' import { useState } from 'react' import { Col } from 'web/components/layout/col' @@ -65,7 +65,7 @@ export const FollowMarketButton = (props: { Watch )} - + } + + const emailsEnabled: Array = [ + 'all_comments_on_watched_markets', + 'all_replies_to_my_comments_on_watched_markets', + 'all_comments_on_contracts_with_shares_in_on_watched_markets', + + 'all_answers_on_watched_markets', + 'all_replies_to_my_answers_on_watched_markets', + 'all_answers_on_contracts_with_shares_in_on_watched_markets', + + 'your_contract_closed', + 'all_comments_on_my_markets', + 'all_answers_on_my_markets', + + 'resolutions_on_watched_markets_with_shares_in', + 'resolutions_on_watched_markets', + + 'tagged_user', + 'trending_markets', + 'onboarding_flow', + 'thank_you_for_purchases', + + // TODO: add these + // 'contract_from_followed_user', + // 'referral_bonuses', + // 'unique_bettors_on_your_contract', + // 'tips_on_your_markets', + // 'tips_on_your_comments', + // 'subsidized_your_market', + // 'on_new_follow', + // maybe the following? + // 'profit_loss_updates', + // 'probability_updates_on_watched_markets', + // 'limit_order_fills', + ] + const browserDisabled: Array = [ + 'trending_markets', + 'profit_loss_updates', + 'onboarding_flow', + 'thank_you_for_purchases', + ] + + type sectionData = { + label: string + subscriptionTypeToDescription: { + [key in keyof Partial]: string + } + } + + const comments: sectionData = { + label: 'New Comments', + subscriptionTypeToDescription: { + all_comments_on_watched_markets: 'All new comments', + all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, + all_replies_to_my_comments_on_watched_markets: + 'Only replies to your comments', + // comments_by_followed_users_on_watched_markets: 'By followed users', + }, + } + + const answers: sectionData = { + label: 'New Answers', + subscriptionTypeToDescription: { + all_answers_on_watched_markets: 'All new answers', + all_answers_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, + all_replies_to_my_answers_on_watched_markets: + 'Only replies to your answers', + // answers_by_followed_users_on_watched_markets: 'By followed users', + // answers_by_market_creator_on_watched_markets: 'By market creator', + }, + } + const updates: sectionData = { + label: 'Updates & Resolutions', + subscriptionTypeToDescription: { + market_updates_on_watched_markets: 'All creator updates', + market_updates_on_watched_markets_with_shares_in: `Only creator updates on markets you're invested in`, + resolutions_on_watched_markets: 'All market resolutions', + resolutions_on_watched_markets_with_shares_in: `Only market resolutions you're invested in`, + // probability_updates_on_watched_markets: 'Probability updates', + }, + } + const yourMarkets: sectionData = { + label: 'Markets You Created', + subscriptionTypeToDescription: { + your_contract_closed: 'Your market has closed (and needs resolution)', + all_comments_on_my_markets: 'Comments on your markets', + all_answers_on_my_markets: 'Answers on your markets', + subsidized_your_market: 'Your market was subsidized', + tips_on_your_markets: 'Likes on your markets', + }, + } + const bonuses: sectionData = { + label: 'Bonuses', + subscriptionTypeToDescription: { + betting_streaks: 'Betting streak bonuses', + referral_bonuses: 'Referral bonuses from referring users', + unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets', + }, + } + const otherBalances: sectionData = { + label: 'Other', + subscriptionTypeToDescription: { + loan_income: 'Automatic loans from your profitable bets', + limit_order_fills: 'Limit order fills', + tips_on_your_comments: 'Tips on your comments', + }, + } + const userInteractions: sectionData = { + label: 'Users', + subscriptionTypeToDescription: { + tagged_user: 'A user tagged you', + on_new_follow: 'Someone followed you', + contract_from_followed_user: 'New markets created by users you follow', + }, + } + const generalOther: sectionData = { + label: 'Other', + subscriptionTypeToDescription: { + trending_markets: 'Weekly interesting markets', + thank_you_for_purchases: 'Thank you notes for your purchases', + onboarding_flow: 'Explanatory emails to help you get started', + // profit_loss_updates: 'Weekly profit/loss updates', + }, + } + + const NotificationSettingLine = ( + description: string, + key: keyof notification_subscription_types, + value: notification_destination_types[] + ) => { + const previousInAppValue = value.includes('browser') + const previousEmailValue = value.includes('email') + const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue) + const [emailEnabled, setEmailEnabled] = useState(previousEmailValue) + const loading = 'Changing Notifications Settings' + const success = 'Changed Notification Settings!' + const highlight = navigateToSection === key + + useEffect(() => { + if ( + inAppEnabled !== previousInAppValue || + emailEnabled !== previousEmailValue + ) { + toast.promise( + updatePrivateUser(privateUser.id, { + notificationSubscriptionTypes: { + ...privateUser.notificationSubscriptionTypes, + [key]: filterDefined([ + inAppEnabled ? 'browser' : undefined, + emailEnabled ? 'email' : undefined, + ]), + }, + }), + { + success, + loading, + error: 'Error changing notification settings. Try again?', + } + ) + } + }, [ + inAppEnabled, + emailEnabled, + previousInAppValue, + previousEmailValue, + key, + ]) + + return ( + + + + {description} + + + {!browserDisabled.includes(key) && ( + + )} + {emailsEnabled.includes(key) && ( + + )} + + + + ) + } + + const getUsersSavedPreference = ( + key: keyof notification_subscription_types + ) => { + return privateUser.notificationSubscriptionTypes[key] ?? [] + } + + const Section = (icon: ReactNode, data: sectionData) => { + const { label, subscriptionTypeToDescription } = data + const expand = + navigateToSection && + Object.keys(subscriptionTypeToDescription).includes(navigateToSection) + const [expanded, setExpanded] = useState(expand) + + // Not working as the default value for expanded, so using a useEffect + useEffect(() => { + if (expand) setExpanded(true) + }, [expand]) + + return ( + + setExpanded(!expanded)} + > + {icon} + {label} + + {expanded ? ( + + Hide + + ) : ( + + Show + + )} + + + {Object.entries(subscriptionTypeToDescription).map(([key, value]) => + NotificationSettingLine( + value, + key as keyof notification_subscription_types, + getUsersSavedPreference( + key as keyof notification_subscription_types + ) + ) + )} + + + ) + } + + return ( +
+
+ + Notifications for Watched Markets + setShowWatchModal(true)} + /> + + {Section(, comments)} + {Section(, answers)} + {Section(, updates)} + {Section(, yourMarkets)} + + Balance Changes + + {Section(, bonuses)} + {Section(, otherBalances)} + + General + + {Section(, userInteractions)} + {Section(, generalOther)} + + + + ) +} diff --git a/web/components/switch-setting.tsx b/web/components/switch-setting.tsx new file mode 100644 index 00000000..0e93c420 --- /dev/null +++ b/web/components/switch-setting.tsx @@ -0,0 +1,34 @@ +import { Switch } from '@headlessui/react' +import clsx from 'clsx' +import React from 'react' + +export const SwitchSetting = (props: { + checked: boolean + onChange: (checked: boolean) => void + label: string +}) => { + const { checked, onChange, label } = props + return ( + + + + + {label} + + + ) +} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 473facd4..d8ce025e 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { notification_subscribe_types, PrivateUser } from 'common/user' +import { PrivateUser } from 'common/user' import { Notification } from 'common/notification' import { getNotificationsQuery } from 'web/lib/firebase/notifications' import { groupBy, map, partition } from 'lodash' @@ -23,11 +23,8 @@ function useNotifications(privateUser: PrivateUser) { if (!result.data) return undefined const notifications = result.data as Notification[] - return getAppropriateNotifications( - notifications, - privateUser.notificationPreferences - ).filter((n) => !n.isSeenOnHref) - }, [privateUser.notificationPreferences, result.data]) + return notifications.filter((n) => !n.isSeenOnHref) + }, [result.data]) return notifications } @@ -111,29 +108,3 @@ export function groupNotifications(notifications: Notification[]) { }) return notificationGroups } - -const lessPriorityReasons = [ - 'on_contract_with_users_comment', - 'on_contract_with_users_answer', - // Notifications not currently generated for users who've sold their shares - 'on_contract_with_users_shares_out', - // Not sure if users will want to see these w/ less: - // 'on_contract_with_users_shares_in', -] - -function getAppropriateNotifications( - notifications: Notification[], - notificationPreferences?: notification_subscribe_types -) { - if (notificationPreferences === 'all') return notifications - if (notificationPreferences === 'less') - return notifications.filter( - (n) => - n.reason && - // Show all contract notifications and any that aren't in the above list: - (n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason)) - ) - if (notificationPreferences === 'none') return [] - - return notifications -} diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index d10812bf..57e13fb9 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,6 +1,6 @@ -import { Tabs } from 'web/components/layout/tabs' +import { ControlledTabs } from 'web/components/layout/tabs' import React, { useEffect, useMemo, useState } from 'react' -import Router from 'next/router' +import Router, { useRouter } from 'next/router' import { Notification, notification_source_types } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' @@ -40,7 +40,7 @@ import { Pagination } from 'web/components/pagination' import { useWindowSize } from 'web/hooks/use-window-size' import { safeLocalStorage } from 'web/lib/util/local' import { SiteLink } from 'web/components/site-link' -import { NotificationSettings } from 'web/components/NotificationSettings' +import { NotificationSettings } from 'web/components/notification-settings' import { SEO } from 'web/components/SEO' import { usePrivateUser, useUser } from 'web/hooks/use-user' import { UserLink } from 'web/components/user-link' @@ -56,24 +56,49 @@ const HIGHLIGHT_CLASS = 'bg-indigo-50' export default function Notifications() { const privateUser = usePrivateUser() + const router = useRouter() + const [navigateToSection, setNavigateToSection] = useState() + const [activeIndex, setActiveIndex] = useState(0) useEffect(() => { if (privateUser === null) Router.push('/') }) + useEffect(() => { + const query = { ...router.query } + if (query.section) { + setNavigateToSection(query.section as string) + setActiveIndex(1) + } + }, [router.query]) + return (
<SEO title="Notifications" description="Manifold user notifications" /> - {privateUser && ( + {privateUser && router.isReady && ( <div> - <Tabs + <ControlledTabs currentPageForAnalytics={'notifications'} labelClassName={'pb-2 pt-1 '} className={'mb-0 sm:mb-2'} - defaultIndex={0} + activeIndex={activeIndex} + onClick={(title, i) => { + router.replace( + { + query: { + ...router.query, + tab: title.toLowerCase(), + section: '', + }, + }, + undefined, + { shallow: true } + ) + setActiveIndex(i) + }} tabs={[ { title: 'Notifications', @@ -82,9 +107,9 @@ export default function Notifications() { { title: 'Settings', content: ( - <div className={''}> - <NotificationSettings /> - </div> + <NotificationSettings + navigateToSection={navigateToSection} + /> ), }, ]} @@ -992,6 +1017,7 @@ function getReasonForShowingNotification( ) { const { sourceType, sourceUpdateType, reason, sourceSlug } = notification let reasonText: string + // TODO: we could leave out this switch and just use the reason field now that they have more information switch (sourceType) { case 'comment': if (reason === 'reply_to_users_answer') @@ -1003,7 +1029,7 @@ function getReasonForShowingNotification( else reasonText = justSummary ? `commented` : `commented on` break case 'contract': - if (reason === 'you_follow_user') + if (reason === 'contract_from_followed_user') reasonText = justSummary ? 'asked the question' : 'asked' else if (sourceUpdateType === 'resolved') reasonText = justSummary ? `resolved the question` : `resolved` @@ -1011,7 +1037,8 @@ function getReasonForShowingNotification( else reasonText = justSummary ? 'updated the question' : `updated` break case 'answer': - if (reason === 'on_users_contract') reasonText = `answered your question ` + if (reason === 'answer_on_your_contract') + reasonText = `answered your question ` else reasonText = `answered` break case 'follow': From 4f19220778642317e01f7bff34a99cca08b581b9 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 12 Sep 2022 11:56:15 -0500 Subject: [PATCH 11/76] Experimental home: accommodate old saved sections. --- web/components/arrange-home.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index 25e814b8..646d30fe 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -7,7 +7,7 @@ import { Row } from 'web/components/layout/row' import { Subtitle } from 'web/components/subtitle' import { useMemberGroups } from 'web/hooks/use-group' import { filterDefined } from 'common/util/array' -import { keyBy } from 'lodash' +import { isArray, keyBy } from 'lodash' import { User } from 'common/user' import { Group } from 'common/group' @@ -107,6 +107,9 @@ const SectionItem = (props: { } export const getHomeItems = (groups: Group[], sections: string[]) => { + // Accommodate old home sections. + if (!isArray(sections)) sections = [] + const items = [ { label: 'Daily movers', id: 'daily-movers' }, { label: 'Trending', id: 'score' }, From 3cb36a36ec35e439f0985a702a59f4e301108cd8 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 11:00:24 -0600 Subject: [PATCH 12/76] Separate email and browser ids list --- functions/src/create-notification.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 356ad200..03bbe8b5 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -222,7 +222,8 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( repliedToId, } = miscData ?? {} - const recipientIdsList: string[] = [] + const browserRecipientIdsList: string[] = [] + const emailRecipientIdsList: string[] = [] const contractFollowersSnap = await firestore .collection(`contracts/${sourceContract.id}/follows`) @@ -271,8 +272,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( ) => { if ( !stillFollowingContract(sourceContract.creatorId) || - sourceUser.id == userId || - recipientIdsList.includes(userId) + sourceUser.id == userId ) return const privateUser = await getPrivateUser(userId) @@ -282,11 +282,11 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( reason ) - if (sendToBrowser) { + if (sendToBrowser && !browserRecipientIdsList.includes(userId)) { await createBrowserNotification(userId, reason) - recipientIdsList.push(userId) + browserRecipientIdsList.push(userId) } - if (sendToEmail) { + if (sendToEmail && !emailRecipientIdsList.includes(userId)) { if (sourceType === 'comment') { // TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment await sendNewCommentEmail( @@ -327,7 +327,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( resolutionData.resolutionProbability, resolutionData.resolutions ) - recipientIdsList.push(userId) + emailRecipientIdsList.push(userId) } } From ff81b859d1015b0de4025e3c26eb31e0c854a29b Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Mon, 12 Sep 2022 20:54:11 +0100 Subject: [PATCH 13/76] "Fix "500 internal error" in large groups (#872) * Fix "500 internal error" in large groups (#856) This reverts commit 28f0c6b1f8f14bd210d02b6032967822d1779b3b. * Ship without touching prod and with some logs. --- common/group.ts | 11 ++ functions/src/update-metrics.ts | 79 +++++++- web/lib/firebase/groups.ts | 5 +- web/pages/group/[...slugs]/index.tsx | 263 +++++++-------------------- 4 files changed, 148 insertions(+), 210 deletions(-) diff --git a/common/group.ts b/common/group.ts index 19f3b7b8..871bc821 100644 --- a/common/group.ts +++ b/common/group.ts @@ -12,7 +12,18 @@ export type Group = { aboutPostId?: string chatDisabled?: boolean mostRecentContractAddedTime?: number + cachedLeaderboard?: { + topTraders: { + userId: string + score: number + }[] + topCreators: { + userId: string + score: number + }[] + } } + export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 430f3d33..4352e872 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -4,9 +4,11 @@ import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract, CPMM } from '../../common/contract' + import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' +import { scoreTraders, scoreCreators } from '../../common/scoring' import { calculateCreatorVolume, calculateNewPortfolioMetrics, @@ -15,6 +17,7 @@ import { computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' +import { Group } from 'common/group' const firestore = admin.firestore() @@ -24,16 +27,29 @@ export const updateMetrics = functions .onRun(updateMetricsCore) export async function updateMetricsCore() { - const [users, contracts, bets, allPortfolioHistories] = await Promise.all([ - getValues<User>(firestore.collection('users')), - getValues<Contract>(firestore.collection('contracts')), - getValues<Bet>(firestore.collectionGroup('bets')), - getValues<PortfolioMetrics>( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - ]) + const [users, contracts, bets, allPortfolioHistories, groups] = + await Promise.all([ + getValues<User>(firestore.collection('users')), + getValues<Contract>(firestore.collection('contracts')), + getValues<Bet>(firestore.collectionGroup('bets')), + getValues<PortfolioMetrics>( + firestore + .collectionGroup('portfolioHistory') + .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago + ), + getValues<Group>(firestore.collection('groups')), + ]) + + const contractsByGroup = await Promise.all( + groups.map((group) => { + return getValues( + firestore + .collection('groups') + .doc(group.id) + .collection('groupContracts') + ) + }) + ) log( `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` ) @@ -41,6 +57,7 @@ export async function updateMetricsCore() { const now = Date.now() const betsByContract = groupBy(bets, (bet) => bet.contractId) + const contractUpdates = contracts .filter((contract) => contract.id) .map((contract) => { @@ -162,4 +179,46 @@ export async function updateMetricsCore() { 'set' ) log(`Updated metrics for ${users.length} users.`) + + try { + const groupUpdates = groups.map((group, index) => { + const groupContractIds = contractsByGroup[index] as GroupContractDoc[] + const groupContracts = groupContractIds.map( + (e) => contractsById[e.contractId] + ) + const bets = groupContracts.map((e) => { + return betsByContract[e.id] ?? [] + }) + + const creatorScores = scoreCreators(groupContracts) + const traderScores = scoreTraders(groupContracts, bets) + + const topTraderScores = topUserScores(traderScores) + const topCreatorScores = topUserScores(creatorScores) + + return { + doc: firestore.collection('groups').doc(group.id), + fields: { + cachedLeaderboard: { + topTraders: topTraderScores, + topCreators: topCreatorScores, + }, + }, + } + }) + // Shipping without this for now to check it's working as intended + console.log('Group Leaderboard Updates', groupUpdates) + //await writeAsync(firestore, groupUpdates) + } catch (e) { + console.log('Error While Updating Group Leaderboards', e) + } } + +const topUserScores = (scores: { [userId: string]: number }) => { + const top50 = Object.entries(scores) + .sort(([, scoreA], [, scoreB]) => scoreB - scoreA) + .slice(0, 50) + return top50.map(([userId, score]) => ({ userId, score })) +} + +type GroupContractDoc = { contractId: string; createdTime: number } diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 7a372d9a..f27460d9 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -24,7 +24,6 @@ import { Contract } from 'common/contract' import { getContractFromId, updateContract } from 'web/lib/firebase/contracts' import { db } from 'web/lib/firebase/init' import { filterDefined } from 'common/util/array' -import { getUser } from 'web/lib/firebase/users' export const groups = coll<Group>('groups') export const groupMembers = (groupId: string) => @@ -253,7 +252,7 @@ export function getGroupLinkToDisplay(contract: Contract) { return groupToDisplay } -export async function listMembers(group: Group) { +export async function listMemberIds(group: Group) { const members = await getValues<GroupMemberDoc>(groupMembers(group.id)) - return await Promise.all(members.map((m) => m.userId).map(getUser)) + return members.map((m) => m.userId) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 768e2f82..f5d68e57 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,28 +1,28 @@ import React, { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' -import { debounce, sortBy, take } from 'lodash' -import { SearchIcon } from '@heroicons/react/outline' import { toast } from 'react-hot-toast' import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Page } from 'web/components/page' -import { listAllBets } from 'web/lib/firebase/bets' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { addContractToGroup, getGroupBySlug, groupPath, joinGroup, - listMembers, + listMemberIds, updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group' -import { scoreCreators, scoreTraders } from 'common/scoring' +import { + useGroup, + useGroupContractIds, + useMemberIds, +} from 'web/hooks/use-group' import { Leaderboard } from 'web/components/leaderboard' import { formatMoney } from 'common/util/format' import { EditGroupButton } from 'web/components/groups/edit-group-button' @@ -35,9 +35,7 @@ import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ContractSearch } from 'web/components/contract-search' -import { FollowList } from 'web/components/follow-list' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' -import { searchInAny } from 'common/util/parse' import { CopyLinkButton } from 'web/components/copy-link-button' import { ENV_CONFIG } from 'common/envs/constants' import { useSaveReferral } from 'web/hooks/use-save-referral' @@ -59,7 +57,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const { slugs } = props.params const group = await getGroupBySlug(slugs[0]) - const members = group && (await listMembers(group)) + const memberIds = group && (await listMemberIds(group)) const creatorPromise = group ? getUser(group.creatorId) : null const contracts = @@ -71,19 +69,15 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { : 'open' const aboutPost = group && group.aboutPostId != null && (await getPost(group.aboutPostId)) - const bets = await Promise.all( - contracts.map((contract: Contract) => listAllBets(contract.id)) - ) const messages = group && (await listAllCommentsOnGroup(group.id)) - const creatorScores = scoreCreators(contracts) - const traderScores = scoreTraders(contracts, bets) - const [topCreators, topTraders] = - (members && [ - toTopUsers(creatorScores, members), - toTopUsers(traderScores, members), - ]) ?? - [] + const cachedTopTraderIds = + (group && group.cachedLeaderboard?.topTraders) ?? [] + const cachedTopCreatorIds = + (group && group.cachedLeaderboard?.topCreators) ?? [] + const topTraders = await toTopUsers(cachedTopTraderIds) + + const topCreators = await toTopUsers(cachedTopCreatorIds) const creator = await creatorPromise // Only count unresolved markets @@ -93,11 +87,9 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { props: { contractsCount, group, - members, + memberIds, creator, - traderScores, topTraders, - creatorScores, topCreators, messages, aboutPost, @@ -107,19 +99,6 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { revalidate: 60, // regenerate after a minute } } - -function toTopUsers(userScores: { [userId: string]: number }, users: User[]) { - const topUserPairs = take( - sortBy(Object.entries(userScores), ([_, score]) => -1 * score), - 10 - ).filter(([_, score]) => score >= 0.5) - - const topUsers = topUserPairs.map( - ([userId]) => users.filter((user) => user.id === userId)[0] - ) - return topUsers.filter((user) => user) -} - export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } @@ -134,12 +113,10 @@ const groupSubpages = [ export default function GroupPage(props: { contractsCount: number group: Group | null - members: User[] + memberIds: string[] creator: User - traderScores: { [userId: string]: number } - topTraders: User[] - creatorScores: { [userId: string]: number } - topCreators: User[] + topTraders: { user: User; score: number }[] + topCreators: { user: User; score: number }[] messages: GroupComment[] aboutPost: Post suggestedFilter: 'open' | 'all' @@ -147,24 +124,15 @@ export default function GroupPage(props: { props = usePropz(props, getStaticPropz) ?? { contractsCount: 0, group: null, - members: [], + memberIds: [], creator: null, - traderScores: {}, topTraders: [], - creatorScores: {}, topCreators: [], messages: [], suggestedFilter: 'open', } - const { - contractsCount, - creator, - traderScores, - topTraders, - creatorScores, - topCreators, - suggestedFilter, - } = props + const { contractsCount, creator, topTraders, topCreators, suggestedFilter } = + props const router = useRouter() const { slugs } = router.query as { slugs: string[] } @@ -175,7 +143,7 @@ export default function GroupPage(props: { const user = useUser() const isAdmin = useAdmin() - const members = useMembers(group?.id) ?? props.members + const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds useSaveReferral(user, { defaultReferrerUsername: creator.username, @@ -186,18 +154,25 @@ export default function GroupPage(props: { return <Custom404 /> } const isCreator = user && group && user.id === group.creatorId - const isMember = user && members.map((m) => m.id).includes(user.id) + const isMember = user && memberIds.includes(user.id) + const maxLeaderboardSize = 50 const leaderboard = ( <Col> - <GroupLeaderboards - traderScores={traderScores} - creatorScores={creatorScores} - topTraders={topTraders} - topCreators={topCreators} - members={members} - user={user} - /> + <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> + <GroupLeaderboard + topUsers={topTraders} + title="🏅 Top traders" + header="Profit" + maxToShow={maxLeaderboardSize} + /> + <GroupLeaderboard + topUsers={topCreators} + title="🏅 Top creators" + header="Market volume" + maxToShow={maxLeaderboardSize} + /> + </div> </Col> ) @@ -216,7 +191,7 @@ export default function GroupPage(props: { creator={creator} isCreator={!!isCreator} user={user} - members={members} + memberIds={memberIds} /> </Col> ) @@ -312,9 +287,9 @@ function GroupOverview(props: { creator: User user: User | null | undefined isCreator: boolean - members: User[] + memberIds: string[] }) { - const { group, creator, isCreator, user, members } = props + const { group, creator, isCreator, user, memberIds } = props const anyoneCanJoinChoices: { [key: string]: string } = { Closed: 'false', Open: 'true', @@ -333,7 +308,7 @@ function GroupOverview(props: { const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( group.slug )}${postFix}` - const isMember = user ? members.map((m) => m.id).includes(user.id) : false + const isMember = user ? memberIds.includes(user.id) : false return ( <> @@ -399,155 +374,37 @@ function GroupOverview(props: { /> </Col> )} - - <Col className={'mt-2'}> - <div className="mb-2 text-lg">Members</div> - <GroupMemberSearch members={members} group={group} /> - </Col> </Col> </> ) } -function SearchBar(props: { setQuery: (query: string) => void }) { - const { setQuery } = props - const debouncedQuery = debounce(setQuery, 50) - return ( - <div className={'relative'}> - <SearchIcon className={'absolute left-5 top-3.5 h-5 w-5 text-gray-500'} /> - <input - type="text" - onChange={(e) => debouncedQuery(e.target.value)} - placeholder="Find a member" - className="input input-bordered mb-4 w-full pl-12" - /> - </div> - ) -} - -function GroupMemberSearch(props: { members: User[]; group: Group }) { - const [query, setQuery] = useState('') - const { group } = props - let { members } = props - - // Use static members on load, but also listen to member changes: - const listenToMembers = useMembers(group.id) - if (listenToMembers) { - members = listenToMembers - } - - // TODO use find-active-contracts to sort by? - const matches = sortBy(members, [(member) => member.name]).filter((m) => - searchInAny(query, m.name, m.username) - ) - const matchLimit = 25 - - return ( - <div> - <SearchBar setQuery={setQuery} /> - <Col className={'gap-2'}> - {matches.length > 0 && ( - <FollowList userIds={matches.slice(0, matchLimit).map((m) => m.id)} /> - )} - {matches.length > 25 && ( - <div className={'text-center'}> - And {matches.length - matchLimit} more... - </div> - )} - </Col> - </div> - ) -} - -function SortedLeaderboard(props: { - users: User[] - scoreFunction: (user: User) => number +function GroupLeaderboard(props: { + topUsers: { user: User; score: number }[] title: string + maxToShow: number header: string - maxToShow?: number }) { - const { users, scoreFunction, title, header, maxToShow } = props - const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a)) + const { topUsers, title, maxToShow, header } = props + + const scoresByUser = topUsers.reduce((acc, { user, score }) => { + acc[user.id] = score + return acc + }, {} as { [key: string]: number }) + return ( <Leaderboard className="max-w-xl" - users={sortedUsers} + users={topUsers.map((t) => t.user)} title={title} columns={[ - { header, renderCell: (user) => formatMoney(scoreFunction(user)) }, + { header, renderCell: (user) => formatMoney(scoresByUser[user.id]) }, ]} maxToShow={maxToShow} /> ) } -function GroupLeaderboards(props: { - traderScores: { [userId: string]: number } - creatorScores: { [userId: string]: number } - topTraders: User[] - topCreators: User[] - members: User[] - user: User | null | undefined -}) { - const { traderScores, creatorScores, members, topTraders, topCreators } = - props - const maxToShow = 50 - // Consider hiding M$0 - // If it's just one member (curator), show all bettors, otherwise just show members - return ( - <Col> - <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> - {members.length > 1 ? ( - <> - <SortedLeaderboard - users={members} - scoreFunction={(user) => traderScores[user.id] ?? 0} - title="🏅 Top traders" - header="Profit" - maxToShow={maxToShow} - /> - <SortedLeaderboard - users={members} - scoreFunction={(user) => creatorScores[user.id] ?? 0} - title="🏅 Top creators" - header="Market volume" - maxToShow={maxToShow} - /> - </> - ) : ( - <> - <Leaderboard - className="max-w-xl" - title="🏅 Top traders" - users={topTraders} - columns={[ - { - header: 'Profit', - renderCell: (user) => formatMoney(traderScores[user.id] ?? 0), - }, - ]} - maxToShow={maxToShow} - /> - <Leaderboard - className="max-w-xl" - title="🏅 Top creators" - users={topCreators} - columns={[ - { - header: 'Market volume', - renderCell: (user) => - formatMoney(creatorScores[user.id] ?? 0), - }, - ]} - maxToShow={maxToShow} - /> - </> - )} - </div> - </Col> - ) -} - function AddContractButton(props: { group: Group; user: User }) { const { group, user } = props const [open, setOpen] = useState(false) @@ -684,3 +541,15 @@ function JoinGroupButton(props: { </div> ) } + +const toTopUsers = async ( + cachedUserIds: { userId: string; score: number }[] +): Promise<{ user: User; score: number }[]> => + ( + await Promise.all( + cachedUserIds.map(async (e) => { + const user = await getUser(e.userId) + return { user, score: e.score ?? 0 } + }) + ) + ).filter((e) => e.user != null) From 7d9908dbd0dd46b73ffadfabaaec6ac415d20849 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Mon, 12 Sep 2022 20:58:12 +0100 Subject: [PATCH 14/76] Fix type error in update-metrics --- functions/src/update-metrics.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 4352e872..eb5f6fd8 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -187,7 +187,11 @@ export async function updateMetricsCore() { (e) => contractsById[e.contractId] ) const bets = groupContracts.map((e) => { - return betsByContract[e.id] ?? [] + if (e.id in betsByContract) { + return betsByContract[e.id] ?? [] + } else { + return [] + } }) const creatorScores = scoreCreators(groupContracts) From 86422f90eaf8c66a12cc93d2247ffca90ee7387f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 14:17:39 -0600 Subject: [PATCH 15/76] Set all overflow notifs to seen --- web/components/notification-settings.tsx | 7 +++---- web/pages/notifications.tsx | 21 ++++++++++++++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 756adbfd..408cc245 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -54,21 +54,20 @@ export function NotificationSettings(props: { 'resolutions_on_watched_markets_with_shares_in', 'resolutions_on_watched_markets', - 'tagged_user', 'trending_markets', 'onboarding_flow', 'thank_you_for_purchases', // TODO: add these + 'tagged_user', // 'contract_from_followed_user', // 'referral_bonuses', // 'unique_bettors_on_your_contract', + // 'on_new_follow', + // 'profit_loss_updates', // 'tips_on_your_markets', // 'tips_on_your_comments', - // 'subsidized_your_market', - // 'on_new_follow', // maybe the following? - // 'profit_loss_updates', // 'probability_updates_on_watched_markets', // 'limit_order_fills', ] diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 57e13fb9..0c748ec7 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -26,6 +26,7 @@ import { import { NotificationGroup, useGroupedNotifications, + useUnseenGroupedNotification, } from 'web/hooks/use-notifications' import { TrendingUpIcon } from '@heroicons/react/outline' import { formatMoney } from 'common/util/format' @@ -153,16 +154,13 @@ function NotificationsList(props: { privateUser: PrivateUser }) { const { privateUser } = props const [page, setPage] = useState(0) const allGroupedNotifications = useGroupedNotifications(privateUser) + const unseenGroupedNotifications = useUnseenGroupedNotification(privateUser) const paginatedGroupedNotifications = useMemo(() => { if (!allGroupedNotifications) return const start = page * NOTIFICATIONS_PER_PAGE const end = start + NOTIFICATIONS_PER_PAGE const maxNotificationsToShow = allGroupedNotifications.slice(start, end) - const remainingNotification = allGroupedNotifications.slice(end) - for (const notification of remainingNotification) { - if (notification.isSeen) break - else setNotificationsAsSeen(notification.notifications) - } + const local = safeLocalStorage() local?.setItem( 'notification-groups', @@ -171,6 +169,19 @@ function NotificationsList(props: { privateUser: PrivateUser }) { return maxNotificationsToShow }, [allGroupedNotifications, page]) + // Set all notifications that don't fit on the first page to seen + useEffect(() => { + if ( + paginatedGroupedNotifications && + paginatedGroupedNotifications?.length >= NOTIFICATIONS_PER_PAGE + ) { + const allUnseenNotifications = unseenGroupedNotifications + ?.map((ng) => ng.notifications) + .flat() + allUnseenNotifications && setNotificationsAsSeen(allUnseenNotifications) + } + }, [paginatedGroupedNotifications, unseenGroupedNotifications]) + if (!paginatedGroupedNotifications || !allGroupedNotifications) return <LoadingIndicator /> From 4456a771fd624b3179d194f60a155ee85d3603c2 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Mon, 12 Sep 2022 21:25:45 +0100 Subject: [PATCH 16/76] fix type error in update-metrics pt.2 --- functions/src/update-metrics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index eb5f6fd8..bb037e9c 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -187,7 +187,7 @@ export async function updateMetricsCore() { (e) => contractsById[e.contractId] ) const bets = groupContracts.map((e) => { - if (e.id in betsByContract) { + if (e != null && e.id in betsByContract) { return betsByContract[e.id] ?? [] } else { return [] From 0af1ff112b9b8be8759308b38eea65627550ed00 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 14:30:15 -0600 Subject: [PATCH 17/76] Allow users to see 0% FR answers via show more button --- web/components/answers/answers-panel.tsx | 21 +++++++++++++++---- .../answers/create-answer-panel.tsx | 4 ++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 5811403f..e43305bb 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -23,6 +23,7 @@ import { Avatar } from 'web/components/avatar' import { Linkify } from 'web/components/linkify' import { BuyButton } from 'web/components/yes-no-selector' import { UserLink } from 'web/components/user-link' +import { Button } from 'web/components/button' export function AnswersPanel(props: { contract: FreeResponseContract | MultipleChoiceContract @@ -30,13 +31,14 @@ export function AnswersPanel(props: { const { contract } = props const { creatorId, resolution, resolutions, totalBets, outcomeType } = contract + const [showAllAnswers, setShowAllAnswers] = useState(false) const answers = useAnswers(contract.id) ?? contract.answers const [winningAnswers, losingAnswers] = partition( - answers.filter( - (answer) => - (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && - totalBets[answer.id] > 0.000000001 + answers.filter((answer) => + (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && showAllAnswers + ? true + : totalBets[answer.id] > 0 ), (answer) => answer.id === resolution || (resolutions && resolutions[answer.id]) @@ -127,6 +129,17 @@ export function AnswersPanel(props: { </div> </div> ))} + <Row className={'justify-end'}> + {!showAllAnswers && ( + <Button + color={'gray-white'} + onClick={() => setShowAllAnswers(true)} + size={'md'} + > + Show More + </Button> + )} + </Row> </div> </div> )} diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 7e20e92e..58f55327 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -76,7 +76,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { if (existingAnswer) { setAnswerError( existingAnswer - ? `"${existingAnswer.text}" already exists as an answer` + ? `"${existingAnswer.text}" already exists as an answer. Can't see it? Hit the 'Show More' button right above this box.` : '' ) return @@ -237,7 +237,7 @@ const AnswerError = (props: { text: string; level: answerErrorLevel }) => { }[level] ?? '' return ( <div - className={`${colorClass} mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide`} + className={`${colorClass} mb-2 mr-auto self-center text-xs font-medium tracking-wide`} > {text} </div> From e35c0b3b526d029d571e6172c0ef28405fa9be25 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 14:36:54 -0600 Subject: [PATCH 18/76] Only notify followers of new public markets --- functions/src/create-notification.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 03bbe8b5..c9f3ff8f 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -162,8 +162,9 @@ export const createNotification = async ( sourceUpdateType === 'created' && sourceContract ) { - await notifyUsersFollowers(userToReasonTexts) - notifyTaggedUsers(userToReasonTexts, recipients ?? []) + if (sourceContract.visibility === 'public') + await notifyUsersFollowers(userToReasonTexts) + await notifyTaggedUsers(userToReasonTexts, recipients ?? []) return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'contract' && From 3a814a5b5dd8ea6263883e621050b5a62d77a02f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 14:41:30 -0600 Subject: [PATCH 19/76] Detect just settings tab w/o section --- web/pages/notifications.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 0c748ec7..a4c25ed3 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -67,9 +67,11 @@ export default function Notifications() { useEffect(() => { const query = { ...router.query } + if (query.tab === 'settings') { + setActiveIndex(1) + } if (query.section) { setNavigateToSection(query.section as string) - setActiveIndex(1) } }, [router.query]) From 747d5d7c7c875ab13203d8652694b13c28c1b129 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 14:48:16 -0600 Subject: [PATCH 20/76] In app => website --- web/components/notification-settings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 408cc245..7aeef6ed 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -220,14 +220,14 @@ export function NotificationSettings(props: { <SwitchSetting checked={inAppEnabled} onChange={setInAppEnabled} - label={'In App'} + label={'Website'} /> )} {emailsEnabled.includes(key) && ( <SwitchSetting checked={emailEnabled} onChange={setEmailEnabled} - label={'Emails'} + label={'Email'} /> )} </Row> From 5c6fe08bdbbf9d369ffe4dcbfacd3dbeb9b590da Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 14:48:42 -0600 Subject: [PATCH 21/76] Website => Web --- web/components/notification-settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 7aeef6ed..ac2b9ab0 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -220,7 +220,7 @@ export function NotificationSettings(props: { <SwitchSetting checked={inAppEnabled} onChange={setInAppEnabled} - label={'Website'} + label={'Web'} /> )} {emailsEnabled.includes(key) && ( From 2a96ee98f4e3abc050ad8963e82ab8a2e647890f Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Mon, 12 Sep 2022 21:49:15 +0100 Subject: [PATCH 22/76] Fix type error in update metrics pt.3 --- functions/src/update-metrics.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index bb037e9c..99c8df96 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -183,9 +183,9 @@ export async function updateMetricsCore() { try { const groupUpdates = groups.map((group, index) => { const groupContractIds = contractsByGroup[index] as GroupContractDoc[] - const groupContracts = groupContractIds.map( - (e) => contractsById[e.contractId] - ) + const groupContracts = groupContractIds + .map((e) => contractsById[e.contractId]) + .filter((e) => e !== undefined) as Contract[] const bets = groupContracts.map((e) => { if (e != null && e.id in betsByContract) { return betsByContract[e.id] ?? [] From a3da8a7c3c217da252df1daef3b2baa18a6ba5b2 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Mon, 12 Sep 2022 22:01:37 +0100 Subject: [PATCH 23/76] Make update-metrics actually write cached group leaderboards --- functions/src/update-metrics.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 99c8df96..1de8056c 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -210,9 +210,7 @@ export async function updateMetricsCore() { }, } }) - // Shipping without this for now to check it's working as intended - console.log('Group Leaderboard Updates', groupUpdates) - //await writeAsync(firestore, groupUpdates) + await writeAsync(firestore, groupUpdates) } catch (e) { console.log('Error While Updating Group Leaderboards', e) } From 3d3caa7a428901d8876064952b4745fc82096fc3 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 12 Sep 2022 16:50:31 -0500 Subject: [PATCH 24/76] remove comment bet area --- web/components/feed/feed-comments.tsx | 78 +++------------------------ 1 file changed, 8 insertions(+), 70 deletions(-) diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index a3e9f35a..f896ddb5 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -14,9 +14,7 @@ import { OutcomeLabel } from 'web/components/outcome-label' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { firebaseLogin } from 'web/lib/firebase/users' import { createCommentOnContract } from 'web/lib/firebase/comments' -import { BetStatusText } from 'web/components/feed/feed-bets' import { Col } from 'web/components/layout/col' -import { getProbability } from 'common/calculate' import { track } from 'web/lib/service/analytics' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' @@ -301,74 +299,14 @@ export function ContractCommentInput(props: { const { id } = mostRecentCommentableBet || { id: undefined } return ( - <Col> - <CommentBetArea - betsByCurrentUser={props.betsByCurrentUser} - contract={props.contract} - commentsByCurrentUser={props.commentsByCurrentUser} - parentAnswerOutcome={props.parentAnswerOutcome} - user={useUser()} - className={props.className} - mostRecentCommentableBet={mostRecentCommentableBet} - /> - <CommentInput - replyToUser={props.replyToUser} - parentAnswerOutcome={props.parentAnswerOutcome} - parentCommentId={props.parentCommentId} - onSubmitComment={onSubmitComment} - className={props.className} - presetId={id} - /> - </Col> - ) -} - -function CommentBetArea(props: { - betsByCurrentUser: Bet[] - contract: Contract - commentsByCurrentUser: ContractComment[] - parentAnswerOutcome?: string - user?: User | null - className?: string - mostRecentCommentableBet?: Bet -}) { - const { betsByCurrentUser, contract, user, mostRecentCommentableBet } = props - - const { userPosition, outcome } = getBettorsLargestPositionBeforeTime( - contract, - Date.now(), - betsByCurrentUser - ) - - const isNumeric = contract.outcomeType === 'NUMERIC' - - return ( - <Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}> - <div className="mb-1 text-gray-500"> - {mostRecentCommentableBet && ( - <BetStatusText - contract={contract} - bet={mostRecentCommentableBet} - isSelf={true} - hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'} - /> - )} - {!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && ( - <> - {"You're"} - <CommentStatus - outcome={outcome} - contract={contract} - prob={ - contract.outcomeType === 'BINARY' - ? getProbability(contract) - : undefined - } - /> - </> - )} - </div> - </Row> + <CommentInput + replyToUser={props.replyToUser} + parentAnswerOutcome={props.parentAnswerOutcome} + parentCommentId={props.parentCommentId} + onSubmitComment={onSubmitComment} + className={props.className} + presetId={id} + /> ) } From 0e5b1a77421a5f0c406e4b164dbf91a65163843b Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 12 Sep 2022 17:30:51 -0500 Subject: [PATCH 25/76] market intro panel --- web/components/bet-panel.tsx | 6 ++---- web/components/market-intro-panel.tsx | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 web/components/market-intro-panel.tsx diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 78934389..00db5cb4 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -42,6 +42,7 @@ import { YesNoSelector } from './yes-no-selector' import { PlayMoneyDisclaimer } from './play-money-disclaimer' import { isAndroid, isIOS } from 'web/lib/util/device' import { WarningConfirmationButton } from './warning-confirmation-button' +import { MarketIntroPanel } from './market-intro-panel' export function BetPanel(props: { contract: CPMMBinaryContract | PseudoNumericContract @@ -90,10 +91,7 @@ export function BetPanel(props: { /> </> ) : ( - <> - <BetSignUpPrompt /> - <PlayMoneyDisclaimer /> - </> + <MarketIntroPanel /> )} </Col> diff --git a/web/components/market-intro-panel.tsx b/web/components/market-intro-panel.tsx new file mode 100644 index 00000000..a4ea698e --- /dev/null +++ b/web/components/market-intro-panel.tsx @@ -0,0 +1,26 @@ +import Image from 'next/future/image' + +import { Col } from './layout/col' +import { BetSignUpPrompt } from './sign-up-prompt' + +export function MarketIntroPanel() { + return ( + <Col> + <div className="text-xl">Play-money predictions</div> + + <Image + height={150} + width={150} + className="self-center" + src="/flappy-logo.gif" + /> + + <div className="text-sm mb-4"> + Manifold Markets is a play-money prediction market platform where you can + forecast anything. + </div> + + <BetSignUpPrompt /> + </Col> + ) +} From 8e41b399360d3c3cda95062be10fff58ee197c6c Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 12 Sep 2022 17:34:13 -0500 Subject: [PATCH 26/76] landing page: use next image for logo --- web/components/landing-page-panel.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/components/landing-page-panel.tsx b/web/components/landing-page-panel.tsx index a5b46b08..f0dae17d 100644 --- a/web/components/landing-page-panel.tsx +++ b/web/components/landing-page-panel.tsx @@ -1,3 +1,4 @@ +import Image from 'next/future/image' import { SparklesIcon } from '@heroicons/react/solid' import { Contract } from 'common/contract' @@ -18,7 +19,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) { return ( <> <Col className="mb-6 rounded-xl sm:m-12 sm:mt-0"> - <img + <Image height={250} width={250} className="self-center" From d66a81bc6b330ad0ed00c38a30c9a8eeaba66bab Mon Sep 17 00:00:00 2001 From: mantikoros <mantikoros@users.noreply.github.com> Date: Mon, 12 Sep 2022 22:35:32 +0000 Subject: [PATCH 27/76] Auto-prettification --- web/components/market-intro-panel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/market-intro-panel.tsx b/web/components/market-intro-panel.tsx index a4ea698e..ef4d28a2 100644 --- a/web/components/market-intro-panel.tsx +++ b/web/components/market-intro-panel.tsx @@ -15,9 +15,9 @@ export function MarketIntroPanel() { src="/flappy-logo.gif" /> - <div className="text-sm mb-4"> - Manifold Markets is a play-money prediction market platform where you can - forecast anything. + <div className="mb-4 text-sm"> + Manifold Markets is a play-money prediction market platform where you + can forecast anything. </div> <BetSignUpPrompt /> From f49cb9b399d361ddc08fe7596b112626672a1ecc Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 12 Sep 2022 17:40:17 -0500 Subject: [PATCH 28/76] Only show 'Show more' for free response answers if there are more answers to show --- web/components/answers/answers-panel.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index e43305bb..7ab5e804 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -34,6 +34,8 @@ export function AnswersPanel(props: { const [showAllAnswers, setShowAllAnswers] = useState(false) const answers = useAnswers(contract.id) ?? contract.answers + const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] === 0) + const [winningAnswers, losingAnswers] = partition( answers.filter((answer) => (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && showAllAnswers @@ -130,7 +132,7 @@ export function AnswersPanel(props: { </div> ))} <Row className={'justify-end'}> - {!showAllAnswers && ( + {hasZeroBetAnswers && !showAllAnswers && ( <Button color={'gray-white'} onClick={() => setShowAllAnswers(true)} From 018eb8fbfcec2b0f2c34cf6133e061fd61d8ae3a Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 17:01:59 -0600 Subject: [PATCH 29/76] Send notif to all users in reply chain as reply --- functions/src/create-notification.ts | 47 ++++++++++--------- .../src/on-create-comment-on-contract.ts | 42 ++++++++++++++--- 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index c9f3ff8f..2815655f 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -188,6 +188,15 @@ export const createNotification = async ( } } +export type replied_users_info = { + [key: string]: { + repliedToType: 'comment' | 'answer' + repliedToAnswerText: string | undefined + repliedToId: string | undefined + bet: Bet | undefined + } +} + export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceId: string, sourceType: 'comment' | 'answer' | 'contract', @@ -197,11 +206,8 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceText: string, sourceContract: Contract, miscData?: { - repliedToType?: 'comment' | 'answer' - repliedToId?: string - repliedToContent?: string - repliedUserId?: string - taggedUserIds?: string[] + repliedUsersInfo: replied_users_info + taggedUserIds: string[] }, resolutionData?: { bets: Bet[] @@ -215,13 +221,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( resolutions?: { [outcome: string]: number } } ) => { - const { - repliedToType, - repliedToContent, - repliedUserId, - taggedUserIds, - repliedToId, - } = miscData ?? {} + const { repliedUsersInfo, taggedUserIds } = miscData ?? {} const browserRecipientIdsList: string[] = [] const emailRecipientIdsList: string[] = [] @@ -289,6 +289,8 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( } if (sendToEmail && !emailRecipientIdsList.includes(userId)) { if (sourceType === 'comment') { + const { repliedToType, repliedToAnswerText, repliedToId, bet } = + repliedUsersInfo?.[userId] ?? {} // TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment await sendNewCommentEmail( reason, @@ -297,9 +299,8 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceContract, sourceText, sourceId, - // TODO: Add any paired bets to the comment - undefined, - repliedToType === 'answer' ? repliedToContent : undefined, + bet, + repliedToAnswerText, repliedToType === 'answer' ? repliedToId : undefined ) } else if (sourceType === 'answer') @@ -437,12 +438,16 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( } const notifyRepliedUser = async () => { - if (sourceType === 'comment' && repliedUserId && repliedToType) - await sendNotificationsIfSettingsPermit( - repliedUserId, - repliedToType === 'answer' - ? 'reply_to_users_answer' - : 'reply_to_users_comment' + if (sourceType === 'comment' && repliedUsersInfo) + await Promise.all( + Object.keys(repliedUsersInfo).map((userId) => + sendNotificationsIfSettingsPermit( + userId, + repliedUsersInfo[userId].repliedToType === 'answer' + ? 'reply_to_users_answer' + : 'reply_to_users_comment' + ) + ) ) } diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index a46420bc..65e32dca 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -5,7 +5,10 @@ import { getContract, getUser, getValues } from './utils' import { ContractComment } from '../../common/comment' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' -import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' +import { + createCommentOrAnswerOrUpdatedContractNotification, + replied_users_info, +} from './create-notification' import { parseMentions, richTextToString } from '../../common/util/parse' import { addUserToContractFollowers } from './follow-market' @@ -83,6 +86,36 @@ export const onCreateCommentOnContract = functions ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId + const mentionedUsers = compact(parseMentions(comment.content)) + const repliedUsers: replied_users_info = {} + + // The parent of the reply chain could be a comment or an answer + if (repliedUserId && repliedToType) + repliedUsers[repliedUserId] = { + repliedToType, + repliedToAnswerText: answer ? answer.text : undefined, + repliedToId: comment.replyToCommentId || answer?.id, + bet: bet, + } + + const commentsInSameReplyChain = comments.filter((c) => + repliedToType === 'answer' + ? c.answerOutcome === answer?.id + : repliedToType === 'comment' + ? c.replyToCommentId === comment.replyToCommentId + : false + ) + // The rest of the children in the chain are always comments + commentsInSameReplyChain.forEach((c) => { + if (c.userId !== comment.userId && c.userId !== repliedUserId) { + repliedUsers[c.userId] = { + repliedToType: 'comment', + repliedToAnswerText: undefined, + repliedToId: c.id, + bet: undefined, + } + } + }) await createCommentOrAnswerOrUpdatedContractNotification( comment.id, 'comment', @@ -92,11 +125,8 @@ export const onCreateCommentOnContract = functions richTextToString(comment.content), contract, { - repliedToType, - repliedToId: comment.replyToCommentId || answer?.id, - repliedToContent: answer ? answer.text : undefined, - repliedUserId, - taggedUserIds: compact(parseMentions(comment.content)), + repliedUsersInfo: repliedUsers, + taggedUserIds: mentionedUsers, } ) }) From 235140367465b028a063c04a3cb66dedb20dee5c Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 17:04:06 -0600 Subject: [PATCH 30/76] Replies to answers are comments --- web/components/notification-settings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index ac2b9ab0..c319bf32 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -92,6 +92,8 @@ export function NotificationSettings(props: { all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, 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', }, } @@ -101,8 +103,6 @@ export function NotificationSettings(props: { 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`, - all_replies_to_my_answers_on_watched_markets: - 'Only replies to your answers', // answers_by_followed_users_on_watched_markets: 'By followed users', // answers_by_market_creator_on_watched_markets: 'By market creator', }, From 22d224895164906c46950cd750bc8db9ab8efd53 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Mon, 12 Sep 2022 16:10:32 -0700 Subject: [PATCH 31/76] Add floating menu (bold, italic, link) (#867) * Add floating menu (bold, italic, link) * Sanitize and href-ify user input --- common/util/parse.ts | 7 ++++ web/components/editor.tsx | 68 +++++++++++++++++++++++++++++++++++ web/lib/icons/bold-icon.tsx | 20 +++++++++++ web/lib/icons/italic-icon.tsx | 21 +++++++++++ web/lib/icons/link-icon.tsx | 20 +++++++++++ 5 files changed, 136 insertions(+) create mode 100644 web/lib/icons/bold-icon.tsx create mode 100644 web/lib/icons/italic-icon.tsx create mode 100644 web/lib/icons/link-icon.tsx diff --git a/common/util/parse.ts b/common/util/parse.ts index 4fac3225..0bbd5cd9 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -23,8 +23,15 @@ import { Link } from '@tiptap/extension-link' import { Mention } from '@tiptap/extension-mention' import Iframe from './tiptap-iframe' import TiptapTweet from './tiptap-tweet-type' +import { find } from 'linkifyjs' import { uniq } from 'lodash' +/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ +export function getUrl(text: string) { + const results = find(text, 'url') + return results.length ? results[0].href : null +} + export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi const matches = (text.match(regex) || []).map((match) => diff --git a/web/components/editor.tsx b/web/components/editor.tsx index bb947579..745fc3c5 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -2,6 +2,7 @@ import CharacterCount from '@tiptap/extension-character-count' import Placeholder from '@tiptap/extension-placeholder' import { useEditor, + BubbleMenu, EditorContent, JSONContent, Content, @@ -24,13 +25,19 @@ import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' import { EmbedModal } from './editor/embed-modal' import { + CheckIcon, CodeIcon, PhotographIcon, PresentationChartLineIcon, + TrashIcon, } from '@heroicons/react/solid' import { MarketModal } from './editor/market-modal' import { insertContent } from './editor/utils' import { Tooltip } from './tooltip' +import BoldIcon from 'web/lib/icons/bold-icon' +import ItalicIcon from 'web/lib/icons/italic-icon' +import LinkIcon from 'web/lib/icons/link-icon' +import { getUrl } from 'common/util/parse' const DisplayImage = Image.configure({ HTMLAttributes: { @@ -141,6 +148,66 @@ function isValidIframe(text: string) { return /^<iframe.*<\/iframe>$/.test(text) } +function FloatingMenu(props: { editor: Editor | null }) { + const { editor } = props + + const [url, setUrl] = useState<string | null>(null) + + if (!editor) return null + + // current selection + const isBold = editor.isActive('bold') + const isItalic = editor.isActive('italic') + const isLink = editor.isActive('link') + + const setLink = () => { + const href = url && getUrl(url) + if (href) { + editor.chain().focus().extendMarkRange('link').setLink({ href }).run() + } + } + + const unsetLink = () => editor.chain().focus().unsetLink().run() + + return ( + <BubbleMenu + editor={editor} + className="flex gap-2 rounded-sm bg-slate-700 p-1 text-white" + > + {url === null ? ( + <> + <button onClick={() => editor.chain().focus().toggleBold().run()}> + <BoldIcon className={clsx('h-5', isBold && 'text-indigo-200')} /> + </button> + <button onClick={() => editor.chain().focus().toggleItalic().run()}> + <ItalicIcon + className={clsx('h-5', isItalic && 'text-indigo-200')} + /> + </button> + <button onClick={() => (isLink ? unsetLink() : setUrl(''))}> + <LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} /> + </button> + </> + ) : ( + <> + <input + type="text" + className="h-5 border-0 bg-inherit text-sm !shadow-none !ring-0" + placeholder="Type or paste a link" + onChange={(e) => setUrl(e.target.value)} + /> + <button onClick={() => (setLink(), setUrl(null))}> + <CheckIcon className="h-5 w-5" /> + </button> + <button onClick={() => (unsetLink(), setUrl(null))}> + <TrashIcon className="h-5 w-5" /> + </button> + </> + )} + </BubbleMenu> + ) +} + export function TextEditor(props: { editor: Editor | null upload: ReturnType<typeof useUploadMutation> @@ -155,6 +222,7 @@ export function TextEditor(props: { {/* hide placeholder when focused */} <div className="relative w-full [&:focus-within_p.is-empty]:before:content-none"> <div className="rounded-lg border border-gray-300 bg-white shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"> + <FloatingMenu editor={editor} /> <EditorContent editor={editor} /> {/* Toolbar, with buttons for images and embeds */} <div className="flex h-9 items-center gap-5 pl-4 pr-1"> diff --git a/web/lib/icons/bold-icon.tsx b/web/lib/icons/bold-icon.tsx new file mode 100644 index 00000000..f4fec497 --- /dev/null +++ b/web/lib/icons/bold-icon.tsx @@ -0,0 +1,20 @@ +// from Feather: https://feathericons.com/ +export default function BoldIcon(props: React.SVGProps<SVGSVGElement>) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + {...props} + > + <path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path> + <path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path> + </svg> + ) +} diff --git a/web/lib/icons/italic-icon.tsx b/web/lib/icons/italic-icon.tsx new file mode 100644 index 00000000..d412ed77 --- /dev/null +++ b/web/lib/icons/italic-icon.tsx @@ -0,0 +1,21 @@ +// from Feather: https://feathericons.com/ +export default function ItalicIcon(props: React.SVGProps<SVGSVGElement>) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + {...props} + > + <line x1="19" y1="4" x2="10" y2="4"></line> + <line x1="14" y1="20" x2="5" y2="20"></line> + <line x1="15" y1="4" x2="9" y2="20"></line> + </svg> + ) +} diff --git a/web/lib/icons/link-icon.tsx b/web/lib/icons/link-icon.tsx new file mode 100644 index 00000000..6323344c --- /dev/null +++ b/web/lib/icons/link-icon.tsx @@ -0,0 +1,20 @@ +// from Feather: https://feathericons.com/ +export default function LinkIcon(props: React.SVGProps<SVGSVGElement>) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + {...props} + > + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path> + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path> + </svg> + ) +} From cb143117e51767f0648deb7755665f62319391a8 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Mon, 12 Sep 2022 16:11:03 -0700 Subject: [PATCH 32/76] Make `parse.richTextToString` more efficient (#848) --- common/package.json | 1 + common/util/parse.ts | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/common/package.json b/common/package.json index 52195398..c652cb69 100644 --- a/common/package.json +++ b/common/package.json @@ -13,6 +13,7 @@ "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/starter-kit": "2.0.0-beta.191", + "prosemirror-model": "1.18.1", "lodash": "4.17.21" }, "devDependencies": { diff --git a/common/util/parse.ts b/common/util/parse.ts index 0bbd5cd9..8efd88f6 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -1,5 +1,12 @@ import { MAX_TAG_LENGTH } from '../contract' -import { generateText, JSONContent } from '@tiptap/core' +import { + getText, + getTextSerializersFromSchema, + getSchema, + JSONContent, +} from '@tiptap/core' +import { Node as ProseMirrorNode } from 'prosemirror-model' + // Tiptap starter extensions import { Blockquote } from '@tiptap/extension-blockquote' import { Bold } from '@tiptap/extension-bold' @@ -97,7 +104,6 @@ export const exhibitExts = [ Paragraph, Strike, Text, - Image, Link, Mention, @@ -105,6 +111,15 @@ export const exhibitExts = [ TiptapTweet, ] +const exhibitSchema = getSchema(exhibitExts) + export function richTextToString(text?: JSONContent) { - return !text ? '' : generateText(text, exhibitExts) + if (!text) { + return '' + } + const contentNode = ProseMirrorNode.fromJSON(exhibitSchema, text) + return getText(contentNode, { + blockSeparator: '\n\n', + textSerializers: getTextSerializersFromSchema(exhibitSchema), + }) } From 483838c1b2b3a85769928f092114e46ff8d24457 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 12 Sep 2022 19:06:37 -0500 Subject: [PATCH 33/76] Revert "Make `parse.richTextToString` more efficient (#848)" This reverts commit cb143117e51767f0648deb7755665f62319391a8. --- common/package.json | 1 - common/util/parse.ts | 21 +++------------------ 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/common/package.json b/common/package.json index c652cb69..52195398 100644 --- a/common/package.json +++ b/common/package.json @@ -13,7 +13,6 @@ "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/starter-kit": "2.0.0-beta.191", - "prosemirror-model": "1.18.1", "lodash": "4.17.21" }, "devDependencies": { diff --git a/common/util/parse.ts b/common/util/parse.ts index 8efd88f6..0bbd5cd9 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -1,12 +1,5 @@ import { MAX_TAG_LENGTH } from '../contract' -import { - getText, - getTextSerializersFromSchema, - getSchema, - JSONContent, -} from '@tiptap/core' -import { Node as ProseMirrorNode } from 'prosemirror-model' - +import { generateText, JSONContent } from '@tiptap/core' // Tiptap starter extensions import { Blockquote } from '@tiptap/extension-blockquote' import { Bold } from '@tiptap/extension-bold' @@ -104,6 +97,7 @@ export const exhibitExts = [ Paragraph, Strike, Text, + Image, Link, Mention, @@ -111,15 +105,6 @@ export const exhibitExts = [ TiptapTweet, ] -const exhibitSchema = getSchema(exhibitExts) - export function richTextToString(text?: JSONContent) { - if (!text) { - return '' - } - const contentNode = ProseMirrorNode.fromJSON(exhibitSchema, text) - return getText(contentNode, { - blockSeparator: '\n\n', - textSerializers: getTextSerializersFromSchema(exhibitSchema), - }) + return !text ? '' : generateText(text, exhibitExts) } From de8c27c97086ac9373fecdae00823f2d0dab124d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 13 Sep 2022 07:48:41 -0600 Subject: [PATCH 34/76] Filter None answer earlier --- web/components/answers/answers-panel.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 7ab5e804..444c5701 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -33,15 +33,13 @@ export function AnswersPanel(props: { contract const [showAllAnswers, setShowAllAnswers] = useState(false) - const answers = useAnswers(contract.id) ?? contract.answers - const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] === 0) + const answers = (useAnswers(contract.id) ?? contract.answers).filter( + (a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE' + ) + const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1) const [winningAnswers, losingAnswers] = partition( - answers.filter((answer) => - (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && showAllAnswers - ? true - : totalBets[answer.id] > 0 - ), + answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)), (answer) => answer.id === resolution || (resolutions && resolutions[answer.id]) ) From 8b1776fe3b8c7466456feea548d4850d548713b2 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 13 Sep 2022 07:53:01 -0600 Subject: [PATCH 35/76] Remove contracts number badge from groups tab --- web/pages/group/[...slugs]/index.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index f5d68e57..f124e225 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -80,12 +80,9 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const topCreators = await toTopUsers(cachedTopCreatorIds) const creator = await creatorPromise - // Only count unresolved markets - const contractsCount = contracts.filter((c) => !c.isResolved).length return { props: { - contractsCount, group, memberIds, creator, @@ -111,7 +108,6 @@ const groupSubpages = [ ] as const export default function GroupPage(props: { - contractsCount: number group: Group | null memberIds: string[] creator: User @@ -122,7 +118,6 @@ export default function GroupPage(props: { suggestedFilter: 'open' | 'all' }) { props = usePropz(props, getStaticPropz) ?? { - contractsCount: 0, group: null, memberIds: [], creator: null, @@ -131,8 +126,7 @@ export default function GroupPage(props: { messages: [], suggestedFilter: 'open', } - const { contractsCount, creator, topTraders, topCreators, suggestedFilter } = - props + const { creator, topTraders, topCreators, suggestedFilter } = props const router = useRouter() const { slugs } = router.query as { slugs: string[] } @@ -208,7 +202,6 @@ export default function GroupPage(props: { const tabs = [ { - badge: `${contractsCount}`, title: 'Markets', content: questionsTab, href: groupPath(group.slug, 'markets'), From 55b895146b53ccf257698e9c97ca5cd565cfc3d5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 13 Sep 2022 07:54:37 -0600 Subject: [PATCH 36/76] Find multiple choice resolution texts as well --- functions/src/resolve-market.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 015ac72f..b867b609 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -166,7 +166,10 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { (bets) => getContractBetMetrics(contract, bets).invested ) let resolutionText = outcome ?? contract.question - if (contract.outcomeType === 'FREE_RESPONSE') { + if ( + contract.outcomeType === 'FREE_RESPONSE' || + contract.outcomeType === 'MULTIPLE_CHOICE' + ) { const answerText = contract.answers.find( (answer) => answer.id === outcome )?.text From 2c922cbae6cde42c82f114f84c32c1f5bc153da5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 13 Sep 2022 08:16:23 -0600 Subject: [PATCH 37/76] Send no-bet resolution emails to those without bets --- .../market-resolved-no-bets.html | 491 ++++++++++++++++++ functions/src/emails.ts | 9 +- 2 files changed, 495 insertions(+), 5 deletions(-) create mode 100644 functions/src/email-templates/market-resolved-no-bets.html diff --git a/functions/src/email-templates/market-resolved-no-bets.html b/functions/src/email-templates/market-resolved-no-bets.html new file mode 100644 index 00000000..ff5f541f --- /dev/null +++ b/functions/src/email-templates/market-resolved-no-bets.html @@ -0,0 +1,491 @@ +<!DOCTYPE html> +<html style=" + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + box-sizing: border-box; + font-size: 14px; + margin: 0; + "> + +<head> + <meta name="viewport" content="width=device-width" /> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <title>Market resolved + + + + + +
+ +
+ + + + + + +
+ + + +
+
+ + + +
+ +
+ + - - - -
- + - -
+ - - - + " target="_blank">click here to manage your notifications. +

+ + + + + +
-
+
-

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

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

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

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

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

+ + + + + +
+
+ + + + +
+ + + + + + + + + + + + + + + + +
+ + Manifold Markets + +
+ {{creatorName}} asked +
+ + {{question}} +
+

+ Resolved {{outcome}} +

+
+ + + + + + + +
+ Dear {{name}}, +
+
+ A market you were following has been resolved! +
+
+ Thanks, +
+ Manifold Team +
+
+
+ +
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/functions/src/emails.ts b/functions/src/emails.ts index b9d34363..d1387ef9 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -56,10 +56,9 @@ export const sendMarketResolutionEmail = async ( ? ` (plus ${formatMoney(creatorPayout)} in commissions)` : '' - const displayedInvestment = - Number.isNaN(investment) || investment < 0 - ? formatMoney(0) - : formatMoney(investment) + const correctedInvestment = + Number.isNaN(investment) || investment < 0 ? 0 : investment + const displayedInvestment = formatMoney(correctedInvestment) const displayedPayout = formatMoney(payout) @@ -81,7 +80,7 @@ export const sendMarketResolutionEmail = async ( return await sendTemplateEmail( privateUser.email, subject, - 'market-resolved', + correctedInvestment === 0 ? 'market-resolved-no-bets' : 'market-resolved', templateData ) } From 4398fa9bda37ec21d81023732c028d353b72c703 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 13 Sep 2022 09:54:51 -0600 Subject: [PATCH 38/76] Add new market from followed user email notification --- functions/src/create-notification.ts | 211 ++++++----- .../new-market-from-followed-user.html | 354 ++++++++++++++++++ functions/src/emails.ts | 34 ++ functions/src/on-create-contract.ts | 10 +- web/components/notification-settings.tsx | 5 +- web/pages/notifications.tsx | 95 ++--- 6 files changed, 567 insertions(+), 142 deletions(-) create mode 100644 functions/src/email-templates/new-market-from-followed-user.html diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 2815655f..84edf715 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -22,7 +22,9 @@ import { sendMarketResolutionEmail, sendNewAnswerEmail, sendNewCommentEmail, + sendNewFollowedMarketEmail, } from './emails' +import { filterDefined } from '../../common/util/array' const firestore = admin.firestore() type recipients_to_reason_texts = { @@ -103,51 +105,14 @@ export const createNotification = async ( privateUser, sourceContract ) - } else if (reason === 'tagged_user') { - // TODO: send email to tagged user in new contract } else if (reason === 'subsidized_your_market') { // TODO: send email to creator of market that was subsidized - } else if (reason === 'contract_from_followed_user') { - // TODO: send email to follower of user who created market } else if (reason === 'on_new_follow') { // TODO: send email to user who was followed } } } - const notifyUsersFollowers = async ( - userToReasonTexts: recipients_to_reason_texts - ) => { - const followers = await firestore - .collectionGroup('follows') - .where('userId', '==', sourceUser.id) - .get() - - followers.docs.forEach((doc) => { - const followerUserId = doc.ref.parent.parent?.id - if ( - followerUserId && - shouldReceiveNotification(followerUserId, userToReasonTexts) - ) { - userToReasonTexts[followerUserId] = { - reason: 'contract_from_followed_user', - } - } - }) - } - - const notifyTaggedUsers = ( - userToReasonTexts: recipients_to_reason_texts, - userIds: (string | undefined)[] - ) => { - userIds.forEach((id) => { - if (id && shouldReceiveNotification(id, userToReasonTexts)) - userToReasonTexts[id] = { - reason: 'tagged_user', - } - }) - } - // The following functions modify the userToReasonTexts object in place. const userToReasonTexts: recipients_to_reason_texts = {} @@ -157,15 +122,6 @@ export const createNotification = async ( reason: 'on_new_follow', } return await sendNotificationsIfSettingsPermit(userToReasonTexts) - } else if ( - sourceType === 'contract' && - sourceUpdateType === 'created' && - sourceContract - ) { - if (sourceContract.visibility === 'public') - await notifyUsersFollowers(userToReasonTexts) - await notifyTaggedUsers(userToReasonTexts, recipients ?? []) - return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'contract' && sourceUpdateType === 'closed' && @@ -283,52 +239,57 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( reason ) + // Browser notifications if (sendToBrowser && !browserRecipientIdsList.includes(userId)) { await createBrowserNotification(userId, reason) browserRecipientIdsList.push(userId) } - if (sendToEmail && !emailRecipientIdsList.includes(userId)) { - if (sourceType === 'comment') { - const { repliedToType, repliedToAnswerText, repliedToId, bet } = - repliedUsersInfo?.[userId] ?? {} - // TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment - await sendNewCommentEmail( - reason, - privateUser, - sourceUser, - sourceContract, - sourceText, - sourceId, - bet, - repliedToAnswerText, - repliedToType === 'answer' ? repliedToId : undefined - ) - } else if (sourceType === 'answer') - await sendNewAnswerEmail( - reason, - privateUser, - sourceUser.name, - sourceText, - sourceContract, - sourceUser.avatarUrl - ) - else if ( - sourceType === 'contract' && - sourceUpdateType === 'resolved' && - resolutionData + + // Emails notifications + if (!sendToEmail || emailRecipientIdsList.includes(userId)) return + if (sourceType === 'comment') { + const { repliedToType, repliedToAnswerText, repliedToId, bet } = + repliedUsersInfo?.[userId] ?? {} + // TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment + await sendNewCommentEmail( + reason, + privateUser, + sourceUser, + sourceContract, + sourceText, + sourceId, + bet, + repliedToAnswerText, + repliedToType === 'answer' ? repliedToId : undefined + ) + emailRecipientIdsList.push(userId) + } else if (sourceType === 'answer') { + await sendNewAnswerEmail( + reason, + privateUser, + sourceUser.name, + sourceText, + sourceContract, + sourceUser.avatarUrl + ) + emailRecipientIdsList.push(userId) + } else if ( + sourceType === 'contract' && + sourceUpdateType === 'resolved' && + resolutionData + ) { + await sendMarketResolutionEmail( + reason, + privateUser, + resolutionData.userInvestments[userId] ?? 0, + resolutionData.userPayouts[userId] ?? 0, + sourceUser, + resolutionData.creatorPayout, + sourceContract, + resolutionData.outcome, + resolutionData.resolutionProbability, + resolutionData.resolutions ) - 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) } } @@ -852,3 +813,79 @@ export const createUniqueBettorBonusNotification = async ( // TODO send email notification } + +export const createNewContractNotification = async ( + contractCreator: User, + contract: Contract, + idempotencyKey: string, + text: string, + mentionedUserIds: string[] +) => { + if (contract.visibility !== 'public') return + + const sendNotificationsIfSettingsAllow = async ( + userId: string, + reason: notification_reason_types + ) => { + const privateUser = await getPrivateUser(userId) + if (!privateUser) return + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + privateUser, + reason + ) + if (sendToBrowser) { + const notificationRef = firestore + .collection(`/users/${userId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: userId, + reason, + createdTime: Date.now(), + isSeen: false, + sourceId: contract.id, + sourceType: 'contract', + sourceUpdateType: 'created', + sourceUserName: contractCreator.name, + sourceUserUsername: contractCreator.username, + sourceUserAvatarUrl: contractCreator.avatarUrl, + sourceText: text, + sourceSlug: contract.slug, + sourceTitle: contract.question, + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + await notificationRef.set(removeUndefinedProps(notification)) + } + if (!sendToEmail) return + if (reason === 'contract_from_followed_user') + await sendNewFollowedMarketEmail(reason, userId, privateUser, contract) + } + const followersSnapshot = await firestore + .collectionGroup('follows') + .where('userId', '==', contractCreator.id) + .get() + + const followerUserIds = filterDefined( + followersSnapshot.docs.map((doc) => { + const followerUserId = doc.ref.parent.parent?.id + return followerUserId && followerUserId != contractCreator.id + ? followerUserId + : undefined + }) + ) + + // As it is coded now, the tag notification usurps the new contract notification + // It'd be easy to append the reason to the eventId if desired + for (const followerUserId of followerUserIds) { + await sendNotificationsIfSettingsAllow( + followerUserId, + 'contract_from_followed_user' + ) + } + for (const mentionedUserId of mentionedUserIds) { + await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user') + } +} diff --git a/functions/src/email-templates/new-market-from-followed-user.html b/functions/src/email-templates/new-market-from-followed-user.html new file mode 100644 index 00000000..877d554f --- /dev/null +++ b/functions/src/email-templates/new-market-from-followed-user.html @@ -0,0 +1,354 @@ + + + + + New market from {{creatorName}} + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + + banner logo + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

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

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

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

+
+
+
+
+ +
+
+ +
+
+ + + \ No newline at end of file diff --git a/functions/src/emails.ts b/functions/src/emails.ts index d1387ef9..da6a5b41 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -510,3 +510,37 @@ function contractUrl(contract: Contract) { function imageSourceUrl(contract: Contract) { return buildCardUrl(getOpenGraphProps(contract)) } + +export const sendNewFollowedMarketEmail = async ( + reason: notification_reason_types, + userId: string, + privateUser: PrivateUser, + contract: Contract +) => { + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser.email || !sendToEmail) return + const user = await getUser(privateUser.id) + if (!user) return + + const { name } = user + const firstName = name.split(' ')[0] + const creatorName = contract.creatorName + + return await sendTemplateEmail( + privateUser.email, + `${creatorName} asked ${contract.question}`, + 'new-market-from-followed-user', + { + name: firstName, + creatorName, + unsubscribeUrl, + questionTitle: contract.question, + questionUrl: contractUrl(contract), + questionImgSrc: imageSourceUrl(contract), + }, + { + from: `${creatorName} on Manifold `, + } + ) +} diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index d9826f6c..b613142b 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -1,7 +1,7 @@ import * as functions from 'firebase-functions' import { getUser } from './utils' -import { createNotification } from './create-notification' +import { createNewContractNotification } from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' @@ -21,13 +21,11 @@ export const onCreateContract = functions const mentioned = parseMentions(desc) await addUserToContractFollowers(contract.id, contractCreator.id) - await createNotification( - contract.id, - 'contract', - 'created', + await createNewContractNotification( contractCreator, + contract, eventId, richTextToString(desc), - { contract, recipients: mentioned } + mentioned ) }) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index c319bf32..65804991 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -58,9 +58,9 @@ export function NotificationSettings(props: { 'onboarding_flow', 'thank_you_for_purchases', + 'tagged_user', // missing tagged on contract description email + 'contract_from_followed_user', // TODO: add these - 'tagged_user', - // 'contract_from_followed_user', // 'referral_bonuses', // 'unique_bettors_on_your_contract', // 'on_new_follow', @@ -90,6 +90,7 @@ export function NotificationSettings(props: { subscriptionTypeToDescription: { all_comments_on_watched_markets: 'All new comments', all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, + // TODO: combine these two all_replies_to_my_comments_on_watched_markets: 'Only replies to your comments', all_replies_to_my_answers_on_watched_markets: diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index a4c25ed3..7ebc473b 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1031,52 +1031,53 @@ function getReasonForShowingNotification( const { sourceType, sourceUpdateType, reason, sourceSlug } = notification let reasonText: string // TODO: we could leave out this switch and just use the reason field now that they have more information - switch (sourceType) { - case 'comment': - if (reason === 'reply_to_users_answer') - reasonText = justSummary ? 'replied' : 'replied to you on' - else if (reason === 'tagged_user') - reasonText = justSummary ? 'tagged you' : 'tagged you on' - else if (reason === 'reply_to_users_comment') - reasonText = justSummary ? 'replied' : 'replied to you on' - else reasonText = justSummary ? `commented` : `commented on` - break - case 'contract': - if (reason === 'contract_from_followed_user') - reasonText = justSummary ? 'asked the question' : 'asked' - else if (sourceUpdateType === 'resolved') - reasonText = justSummary ? `resolved the question` : `resolved` - else if (sourceUpdateType === 'closed') reasonText = `Please resolve` - else reasonText = justSummary ? 'updated the question' : `updated` - break - case 'answer': - if (reason === 'answer_on_your_contract') - reasonText = `answered your question ` - else reasonText = `answered` - break - case 'follow': - reasonText = 'followed you' - break - case 'liquidity': - reasonText = 'added a subsidy to your question' - break - case 'group': - reasonText = 'added you to the group' - break - case 'user': - if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') - reasonText = 'joined to bet on your market' - else if (sourceSlug) reasonText = 'joined because you shared' - else reasonText = 'joined because of you' - break - case 'bet': - reasonText = 'bet against you' - break - case 'challenge': - reasonText = 'accepted your challenge' - break - default: - reasonText = '' - } + if (reason === 'tagged_user') + reasonText = justSummary ? 'tagged you' : 'tagged you on' + else + switch (sourceType) { + case 'comment': + if (reason === 'reply_to_users_answer') + reasonText = justSummary ? 'replied' : 'replied to you on' + else if (reason === 'reply_to_users_comment') + reasonText = justSummary ? 'replied' : 'replied to you on' + else reasonText = justSummary ? `commented` : `commented on` + break + case 'contract': + if (reason === 'contract_from_followed_user') + reasonText = justSummary ? 'asked the question' : 'asked' + else if (sourceUpdateType === 'resolved') + reasonText = justSummary ? `resolved the question` : `resolved` + else if (sourceUpdateType === 'closed') reasonText = `Please resolve` + else reasonText = justSummary ? 'updated the question' : `updated` + break + case 'answer': + if (reason === 'answer_on_your_contract') + reasonText = `answered your question ` + else reasonText = `answered` + break + case 'follow': + reasonText = 'followed you' + break + case 'liquidity': + reasonText = 'added a subsidy to your question' + break + case 'group': + reasonText = 'added you to the group' + break + case 'user': + if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') + reasonText = 'joined to bet on your market' + else if (sourceSlug) reasonText = 'joined because you shared' + else reasonText = 'joined because of you' + break + case 'bet': + reasonText = 'bet against you' + break + case 'challenge': + reasonText = 'accepted your challenge' + break + default: + reasonText = '' + } return reasonText } From c42332627036446869412917d7f22899b5e96ec0 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 13 Sep 2022 16:12:53 -0600 Subject: [PATCH 39/76] Send users emails when they hit 1 and 6 unique bettors --- functions/src/create-notification.ts | 101 ++-- .../email-templates/new-unique-bettor.html | 397 ++++++++++++++ .../email-templates/new-unique-bettors.html | 501 ++++++++++++++++++ functions/src/emails.ts | 61 +++ functions/src/on-create-bet.ts | 6 +- web/components/notification-settings.tsx | 7 +- 6 files changed, 1039 insertions(+), 34 deletions(-) create mode 100644 functions/src/email-templates/new-unique-bettor.html create mode 100644 functions/src/email-templates/new-unique-bettors.html diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 84edf715..e2959dda 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -8,7 +8,7 @@ import { User } from '../../common/user' import { Contract } from '../../common/contract' import { getPrivateUser, getValues } from './utils' import { Comment } from '../../common/comment' -import { uniq } from 'lodash' +import { groupBy, uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' @@ -23,6 +23,7 @@ import { sendNewAnswerEmail, sendNewCommentEmail, sendNewFollowedMarketEmail, + sendNewUniqueBettorsEmail, } from './emails' import { filterDefined } from '../../common/util/array' const firestore = admin.firestore() @@ -774,44 +775,84 @@ export const createUniqueBettorBonusNotification = async ( txnId: string, contract: Contract, amount: number, + uniqueBettorIds: string[], idempotencyKey: string ) => { - console.log('createUniqueBettorBonusNotification') const privateUser = await getPrivateUser(contractCreatorId) if (!privateUser) return - const { sendToBrowser } = await getDestinationsForUser( + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( privateUser, 'unique_bettors_on_your_contract' ) - if (!sendToBrowser) return - - const notificationRef = firestore - .collection(`/users/${contractCreatorId}/notifications`) - .doc(idempotencyKey) - const notification: Notification = { - id: idempotencyKey, - userId: contractCreatorId, - reason: 'unique_bettors_on_your_contract', - createdTime: Date.now(), - isSeen: false, - sourceId: txnId, - sourceType: 'bonus', - sourceUpdateType: 'created', - sourceUserName: bettor.name, - sourceUserUsername: bettor.username, - sourceUserAvatarUrl: bettor.avatarUrl, - sourceText: amount.toString(), - sourceSlug: contract.slug, - sourceTitle: contract.question, - // Perhaps not necessary, but just in case - sourceContractSlug: contract.slug, - sourceContractId: contract.id, - sourceContractTitle: contract.question, - sourceContractCreatorUsername: contract.creatorUsername, + if (sendToBrowser) { + const notificationRef = firestore + .collection(`/users/${contractCreatorId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: contractCreatorId, + reason: 'unique_bettors_on_your_contract', + createdTime: Date.now(), + isSeen: false, + sourceId: txnId, + sourceType: 'bonus', + sourceUpdateType: 'created', + sourceUserName: bettor.name, + sourceUserUsername: bettor.username, + sourceUserAvatarUrl: bettor.avatarUrl, + sourceText: amount.toString(), + sourceSlug: contract.slug, + sourceTitle: contract.question, + // Perhaps not necessary, but just in case + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + await notificationRef.set(removeUndefinedProps(notification)) } - return await notificationRef.set(removeUndefinedProps(notification)) - // TODO send email notification + if (!sendToEmail) return + const uniqueBettorsExcludingCreator = uniqueBettorIds.filter( + (id) => id !== contractCreatorId + ) + // only send on 1st and 6th bettor + if ( + uniqueBettorsExcludingCreator.length !== 1 && + uniqueBettorsExcludingCreator.length !== 6 + ) + return + const totalNewBettorsToReport = + uniqueBettorsExcludingCreator.length === 1 ? 1 : 5 + + const mostRecentUniqueBettors = await getValues( + firestore + .collection('users') + .where( + 'id', + 'in', + uniqueBettorsExcludingCreator.slice( + uniqueBettorsExcludingCreator.length - totalNewBettorsToReport, + uniqueBettorsExcludingCreator.length + ) + ) + ) + + const bets = await getValues( + firestore.collection('contracts').doc(contract.id).collection('bets') + ) + // group bets by bettors + const bettorsToTheirBets = groupBy(bets, (bet) => bet.userId) + await sendNewUniqueBettorsEmail( + 'unique_bettors_on_your_contract', + contractCreatorId, + privateUser, + contract, + uniqueBettorsExcludingCreator.length, + mostRecentUniqueBettors, + bettorsToTheirBets, + Math.round(amount * totalNewBettorsToReport) + ) } export const createNewContractNotification = async ( diff --git a/functions/src/email-templates/new-unique-bettor.html b/functions/src/email-templates/new-unique-bettor.html new file mode 100644 index 00000000..30da8b99 --- /dev/null +++ b/functions/src/email-templates/new-unique-bettor.html @@ -0,0 +1,397 @@ + + + + + + + New unique predictors on your market + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + +
+ + Manifold Markets + +
+
+

+ Hi {{name}},

+
+
+
+

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

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

+ Hi {{name}},

+
+
+
+

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

+
+
+ + + + + + + + + + + + + + + +
+
+ + {{bettor1Name}} + {{bet1Description}} +
+
+
+ + {{bettor2Name}} + {{bet2Description}} +
+
+
+ + {{bettor3Name}} + {{bet3Description}} +
+
+
+ + {{bettor4Name}} + {{bet4Description}} +
+
+
+ + {{bettor5Name}} + {{bet5Description}} +
+
+ +
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/functions/src/emails.ts b/functions/src/emails.ts index da6a5b41..adeb3d12 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -22,6 +22,7 @@ import { notification_reason_types, getDestinationsForUser, } from '../../common/notification' +import { Dictionary } from 'lodash' export const sendMarketResolutionEmail = async ( reason: notification_reason_types, @@ -544,3 +545,63 @@ export const sendNewFollowedMarketEmail = async ( } ) } +export const sendNewUniqueBettorsEmail = async ( + reason: notification_reason_types, + userId: string, + privateUser: PrivateUser, + contract: Contract, + totalPredictors: number, + newPredictors: User[], + userBets: Dictionary<[Bet, ...Bet[]]>, + bonusAmount: number +) => { + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser.email || !sendToEmail) return + const user = await getUser(privateUser.id) + if (!user) return + + const { name } = user + const firstName = name.split(' ')[0] + const creatorName = contract.creatorName + // make the emails stack for the same contract + const subject = `You made a popular market! ${ + contract.question.length > 50 + ? contract.question.slice(0, 50) + '...' + : contract.question + } just got ${ + newPredictors.length + } new predictions. Check out who's betting on it inside.` + const templateData: Record = { + name: firstName, + creatorName, + totalPredictors: totalPredictors.toString(), + bonusString: formatMoney(bonusAmount), + marketTitle: contract.question, + marketUrl: contractUrl(contract), + unsubscribeUrl, + newPredictors: newPredictors.length.toString(), + } + + newPredictors.forEach((p, i) => { + templateData[`bettor${i + 1}Name`] = p.name + if (p.avatarUrl) templateData[`bettor${i + 1}AvatarUrl`] = p.avatarUrl + const bet = userBets[p.id][0] + if (bet) { + const { amount, sale } = bet + templateData[`bet${i + 1}Description`] = `${ + sale || amount < 0 ? 'sold' : 'bought' + } ${formatMoney(Math.abs(amount))}` + } + }) + + return await sendTemplateEmail( + privateUser.email, + subject, + newPredictors.length === 1 ? 'new-unique-bettor' : 'new-unique-bettors', + templateData, + { + from: `Manifold Markets `, + } + ) +} diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 5dbebfc3..f2c6b51a 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -28,8 +28,9 @@ import { User } from '../../common/user' const firestore = admin.firestore() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() -export const onCreateBet = functions.firestore - .document('contracts/{contractId}/bets/{betId}') +export const onCreateBet = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .firestore.document('contracts/{contractId}/bets/{betId}') .onCreate(async (change, context) => { const { contractId } = context.params as { contractId: string @@ -198,6 +199,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( result.txn.id, contract, result.txn.amount, + newUniqueBettorIds, eventId + '-unique-bettor-bonus' ) } diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 65804991..128a89ef 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -60,11 +60,14 @@ export function NotificationSettings(props: { 'tagged_user', // missing tagged on contract description email 'contract_from_followed_user', + 'unique_bettors_on_your_contract', // TODO: add these + // one-click unsubscribe only unsubscribes them from that type only, (well highlighted), then a link to manage the rest of their notifications + // 'profit_loss_updates', - changes in markets you have shares in + // biggest winner, here are the rest of your markets + // 'referral_bonuses', - // 'unique_bettors_on_your_contract', // 'on_new_follow', - // 'profit_loss_updates', // 'tips_on_your_markets', // 'tips_on_your_comments', // maybe the following? From 34bad35cb88cf186df403d031c106c2f9f258e2a Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 13 Sep 2022 16:19:52 -0600 Subject: [PATCH 40/76] Betting=>predicting --- functions/src/emails.ts | 2 +- web/components/contract/contract-details.tsx | 2 +- web/components/notification-settings.tsx | 2 +- web/components/profile/betting-streak-modal.tsx | 7 ++++--- web/pages/create.tsx | 4 ++-- web/pages/notifications.tsx | 10 ++++++---- web/pages/tournaments/index.tsx | 2 +- 7 files changed, 16 insertions(+), 13 deletions(-) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index adeb3d12..e9ef9630 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -571,7 +571,7 @@ export const sendNewUniqueBettorsEmail = async ( : contract.question } just got ${ newPredictors.length - } new predictions. Check out who's betting on it inside.` + } new predictions. Check out who's predicting on it inside.` const templateData: Record = { name: firstName, creatorName, diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index c383d349..e28ab41a 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -294,7 +294,7 @@ export function ExtraMobileContractDetails(props: { {volumeTranslation} diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 128a89ef..c45510ac 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -134,7 +134,7 @@ export function NotificationSettings(props: { const bonuses: sectionData = { label: 'Bonuses', subscriptionTypeToDescription: { - betting_streaks: 'Betting streak bonuses', + betting_streaks: 'Prediction streak bonuses', referral_bonuses: 'Referral bonuses from referring users', unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets', }, diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index 694a0193..a137833c 100644 --- a/web/components/profile/betting-streak-modal.tsx +++ b/web/components/profile/betting-streak-modal.tsx @@ -16,13 +16,14 @@ export function BettingStreakModal(props: {
- - - - - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - -
-
-
-

- 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 59/76] 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 60/76] 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 61/76] 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 62/76] 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 63/76] 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 64/76] 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 65/76] 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 66/76] 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 67/76] 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 68/76] 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 69/76] 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 70/76] 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 71/76] 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 72/76] 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 73/76] 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 74/76] 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 75/76] 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 76/76] 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 ? (