From 7e9007aad1c7d481752292f555c683cf23554b88 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 29 Apr 2022 09:53:51 -0400 Subject: [PATCH 01/68] Fetch markets client side on explore page (to avoid vercel 5MB limit for static props) --- web/pages/markets.tsx | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/web/pages/markets.tsx b/web/pages/markets.tsx index 6149c8b5..81032199 100644 --- a/web/pages/markets.tsx +++ b/web/pages/markets.tsx @@ -2,26 +2,16 @@ import { ContractsGrid, SearchableGrid, } from '../components/contract/contracts-list' +import { LoadingIndicator } from '../components/loading-indicator' import { Page } from '../components/page' import { SEO } from '../components/SEO' import { Title } from '../components/title' import { useContracts } from '../hooks/use-contracts' -import { Contract, listAllContracts } from '../lib/firebase/contracts' - -export async function getStaticProps() { - const contracts = await listAllContracts().catch((_) => []) - return { - props: { - contracts, - }, - - revalidate: 60, // regenerate after a minute - } -} +import { Contract } from '../lib/firebase/contracts' // TODO: Rename endpoint to "Explore" -export default function Markets(props: { contracts: Contract[] }) { - const contracts = useContracts() ?? props.contracts ?? [] +export default function Markets() { + const contracts = useContracts() return ( @@ -30,12 +20,11 @@ export default function Markets(props: { contracts: Contract[] }) { description="Discover what's new, trending, or soon-to-close. Or search among our hundreds of markets." url="/markets" /> - {/* - - - */} - - + {contracts === undefined ? ( + + ) : ( + + )} ) } From fa8ebe36bd8dd6dfd512ebc2e0283fd938e3ca83 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 29 Apr 2022 10:05:32 -0400 Subject: [PATCH 02/68] Move loading indicator into SearchableGrid. --- web/components/contract/contracts-list.tsx | 9 ++++++--- web/pages/markets.tsx | 7 +------ web/pages/tag/[tag].tsx | 7 ++----- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/web/components/contract/contracts-list.tsx b/web/components/contract/contracts-list.tsx index d6bef2e7..dab8613d 100644 --- a/web/components/contract/contracts-list.tsx +++ b/web/components/contract/contracts-list.tsx @@ -18,6 +18,7 @@ import { useQueryAndSortParams, } from '../../hooks/use-sort-and-query-params' import { Answer } from '../../../common/answer' +import { LoadingIndicator } from '../loading-indicator' export function ContractsGrid(props: { contracts: Contract[] @@ -213,7 +214,7 @@ function TagContractsGrid(props: { contracts: Contract[] }) { const MAX_CONTRACTS_DISPLAYED = 99 export function SearchableGrid(props: { - contracts: Contract[] + contracts: Contract[] | undefined byOneCreator?: boolean querySortOptions?: { defaultSort: Sort @@ -230,7 +231,7 @@ export function SearchableGrid(props: { return queryWords.every((word) => corpus.toLowerCase().includes(word)) } - let matches = contracts.filter( + let matches = (contracts ?? []).filter( (c) => check(c.question) || check(c.description) || @@ -324,7 +325,9 @@ export function SearchableGrid(props: { - {sort === 'tag' ? ( + {contracts === undefined ? ( + + ) : sort === 'tag' ? ( ) : !byOneCreator && sort === 'creator' ? ( diff --git a/web/pages/markets.tsx b/web/pages/markets.tsx index 81032199..23ff2adf 100644 --- a/web/pages/markets.tsx +++ b/web/pages/markets.tsx @@ -2,7 +2,6 @@ import { ContractsGrid, SearchableGrid, } from '../components/contract/contracts-list' -import { LoadingIndicator } from '../components/loading-indicator' import { Page } from '../components/page' import { SEO } from '../components/SEO' import { Title } from '../components/title' @@ -20,11 +19,7 @@ export default function Markets() { description="Discover what's new, trending, or soon-to-close. Or search among our hundreds of markets." url="/markets" /> - {contracts === undefined ? ( - - ) : ( - - )} + ) } diff --git a/web/pages/tag/[tag].tsx b/web/pages/tag/[tag].tsx index 34c8f138..cabc6c80 100644 --- a/web/pages/tag/[tag].tsx +++ b/web/pages/tag/[tag].tsx @@ -3,27 +3,24 @@ import { useRouter } from 'next/router' import { SearchableGrid } from '../../components/contract/contracts-list' import { Page } from '../../components/page' import { Title } from '../../components/title' -import { useContracts } from '../../hooks/use-contracts' import { Contract, listTaggedContractsCaseInsensitive, } from '../../lib/firebase/contracts' -export default function TagPage(props: { contracts: Contract[] }) { +export default function TagPage() { const router = useRouter() const { tag } = router.query as { tag: string } // mqp: i wrote this in a panic to make the page literally work at all so if you // want to e.g. listen for new contracts you may want to fix it up - const [contracts, setContracts] = useState('loading') + const [contracts, setContracts] = useState() useEffect(() => { if (tag != null) { listTaggedContractsCaseInsensitive(tag).then(setContracts) } }, [tag]) - if (contracts === 'loading') return <> - return ( From c59e444d0ad5d70bd744b259d369a67597c54e10 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 29 Apr 2022 13:30:27 -0400 Subject: [PATCH 03/68] Manifold CLA --- functions/.github/CONTRIBUTING.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 functions/.github/CONTRIBUTING.md diff --git a/functions/.github/CONTRIBUTING.md b/functions/.github/CONTRIBUTING.md new file mode 100644 index 00000000..4651b133 --- /dev/null +++ b/functions/.github/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Manifold CLA + +**Manifold Markets Contributor License Agreement** + +(Thanks to Beeminder and Discourse.org whose CLA we modeled this on!) + +## Unofficial Summary + +- Manifold can use your contributions +- Manifold can sell things involving your contributions +- You’re legally able to agree to the above +- You’re the one who created these contributions +- Manifold decides what gets included in Manifold +- Manifold does not promise any support + +## Official Agreement + +The document below clarifies the terms under which You (the copyright owner or legal entity authorized by the copyright owner), may make "The Contributions" (software, bug fixes, configuration changes, documentation, or any other materials) to "The Work" (Manifold Markets). This license protects You, "The Company" (Manifold Markets, Inc.) and licensees; it does not change your rights to use your own contributions for any other purpose. + +You and "The Company" (Manifold Markets, Inc.) agree: + +- You grant to "The Company" (Manifold Markets, Inc.) a non-exclusive, irrevocable, worldwide, royalty-free, sublicenseable, relicenseable, transferable license under all of Your relevant intellectual property rights, to use, copy, prepare derivative works of, distribute and publicly perform and display "The Contributions" on any licensing terms, including without limitation: (a) open source licenses like the GNU General Public (v2.0) license; and (b) binary, proprietary, or commercial licenses. Except for the licenses granted herein, You reserve all right, title, and interest in and to "The Contributions". +- You grant to "The Company" a non-exclusive, irrevocable (except as stated in this section), worldwide, royalty-free, sublicenseable, transferable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer "The Work", where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with "The Work" to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or "The Work" to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. +- You are able to grant us these rights. You represent that You are legally entitled to grant the above license(s). If Your employer has rights to intellectual property that You create, You represent that You have received permission to make "The Contributions" on behalf of that employer, or that Your employer has waived such rights for "The Contributions". +- "The Contributions" are your original work. You represent that "The Contributions" are Your original works of authorship, and to Your knowledge, no other person claims, or has the right to claim, any right in any invention or patent related to "The Contributions". You also represent that You are not legally obligated, whether by entering into an agreement or otherwise, in any way that conflicts with the terms of this license. For example, if you have signed an agreement requiring you to assign the intellectual property rights in "The Contributions" to an employer or customer, that would conflict with the terms of this license. +- We, as authoritative representatives of "The Company" determine the code that is in "The Work". You understand that the decision to include "The Contribution(s)" in any project or source repository is entirely that of "The Company", and this agreement does not guarantee that "The Contributions" will be included in any product. +- No Implied Warranties. "The Company" acknowledges that, except as explicitly described in this Agreement, the Contribution is provided on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. From be7e4a5c03927b6cb431109f31af1f5d6f77c514 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 29 Apr 2022 13:39:52 -0400 Subject: [PATCH 04/68] Manifold CLA: readme message, move to top level --- {functions/.github => .github}/CONTRIBUTING.md | 0 README.md | 2 ++ 2 files changed, 2 insertions(+) rename {functions/.github => .github}/CONTRIBUTING.md (100%) diff --git a/functions/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 100% rename from functions/.github/CONTRIBUTING.md rename to .github/CONTRIBUTING.md diff --git a/README.md b/README.md index 56dceae0..98387abe 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ Also: Our docs are currently in [a separate repo](https://github.com/manifoldmar Since we are just now open-sourcing things, we will see how things go. Feel free to open issues, submit PRs, and chat about the process on [Discord][discord]. We would prefer [small PRs][small-prs] that we can effectively evaluate and review -- maybe check in with us first if you are thinking to work on a big change. +By contributing to this codebase, you are agreeing to the terms of the [Manifold CLA](https://github.com/manifoldmarkets/manifold/.github/CONTRIBUTING.md). + If you need additional access to any infrastructure in order to work on something (e.g. Vercel, Firebase) let us know about that on Discord as well. [vercel]: https://vercel.com/ From f078ce4fa9b8b607f2c783e708df58bc46f0ac1e Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 29 Apr 2022 13:40:44 -0400 Subject: [PATCH 05/68] CLA: fix link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 98387abe..b480f60c 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Also: Our docs are currently in [a separate repo](https://github.com/manifoldmar Since we are just now open-sourcing things, we will see how things go. Feel free to open issues, submit PRs, and chat about the process on [Discord][discord]. We would prefer [small PRs][small-prs] that we can effectively evaluate and review -- maybe check in with us first if you are thinking to work on a big change. -By contributing to this codebase, you are agreeing to the terms of the [Manifold CLA](https://github.com/manifoldmarkets/manifold/.github/CONTRIBUTING.md). +By contributing to this codebase, you are agreeing to the terms of the [Manifold CLA](https://github.com/manifoldmarkets/manifold/blob/main/.github/CONTRIBUTING.md). If you need additional access to any infrastructure in order to work on something (e.g. Vercel, Firebase) let us know about that on Discord as well. From c2832039b8ee81b9b21970f1066e9960afa588eb Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Fri, 29 Apr 2022 13:45:10 -0400 Subject: [PATCH 06/68] Link to beeminder & discourse --- .github/CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4651b133..0d4dbd32 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -2,7 +2,7 @@ **Manifold Markets Contributor License Agreement** -(Thanks to Beeminder and Discourse.org whose CLA we modeled this on!) +(Thanks to [Beeminder](http://bmndr.co/cla) and [Discourse.org](https://cla-assistant.io/discourse/discourse) whose CLAs we modeled this on!) ## Unofficial Summary From 5f86637ca56d16889bc3edd09d979196d825637e Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 29 Apr 2022 15:53:13 -0400 Subject: [PATCH 07/68] raise ante to M$100 --- common/antes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/antes.ts b/common/antes.ts index 9a3f48c6..a443f508 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -5,7 +5,7 @@ import { User } from './user' import { LiquidityProvision } from './liquidity-provision' import { noFees } from './fees' -export const FIXED_ANTE = 50 +export const FIXED_ANTE = 100 // deprecated export const PHANTOM_ANTE = 0.001 From 5cb6ee3bca43a4ad071e1146a00805a75c482a5b Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 29 Apr 2022 15:58:01 -0400 Subject: [PATCH 08/68] daily free markets' liquidity provided by @ManifoldMarkets --- common/antes.ts | 6 ++++-- functions/src/create-contract.ts | 5 ++++- functions/src/scripts/migrate-to-cfmm.ts | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/common/antes.ts b/common/antes.ts index a443f508..c77308f4 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -11,8 +11,10 @@ export const FIXED_ANTE = 100 export const PHANTOM_ANTE = 0.001 export const MINIMUM_ANTE = 50 +export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id + export function getCpmmInitialLiquidity( - creator: User, + providerId: string, contract: FullContract<CPMM, Binary>, anteId: string, amount: number @@ -21,7 +23,7 @@ export function getCpmmInitialLiquidity( const lp: LiquidityProvision = { id: anteId, - userId: creator.id, + userId: providerId, contractId: contract.id, createdTime, isAnte: true, diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 781337f7..ae7b0ad8 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -22,6 +22,7 @@ import { getAnteBets, getCpmmInitialLiquidity, getFreeAnswerAnte, + HOUSE_LIQUIDITY_PROVIDER_ID, MINIMUM_ANTE, } from '../../common/antes' import { getNoneAnswer } from '../../common/answer' @@ -144,8 +145,10 @@ export const createContract = functions .collection(`contracts/${contract.id}/liquidity`) .doc() + const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : creator.id + const lp = getCpmmInitialLiquidity( - creator, + providerId, contract as FullContract<CPMM, Binary>, liquidityDoc.id, ante diff --git a/functions/src/scripts/migrate-to-cfmm.ts b/functions/src/scripts/migrate-to-cfmm.ts index cd9177a3..874011ca 100644 --- a/functions/src/scripts/migrate-to-cfmm.ts +++ b/functions/src/scripts/migrate-to-cfmm.ts @@ -106,7 +106,7 @@ async function recalculateContract(contractRef: DocRef, isCommit = false) { const liquidityDocRef = contractRef.collection('liquidity').doc() const lp = getCpmmInitialLiquidity( - { id: 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' } as User, // use @ManifoldMarkets' id + 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2', // use @ManifoldMarkets' id { ...contract, ...contractUpdate, From 78997c1e45afcbb8b10dc7198306af4eb342d253 Mon Sep 17 00:00:00 2001 From: Boa <iansphilips@gmail.com> Date: Fri, 29 Apr 2022 15:11:04 -0600 Subject: [PATCH 09/68] Show comments position (#110) * Add betting activity back to feed * Show position in bin. markets, no comments on bets * Degroup bets on Bets tab * Show users position or recent bet with comments * Add tooltip on answer to FR comments * Style improvements * Only use bets by current user for comment input --- web/components/feed/activity-items.ts | 91 ++++--- web/components/feed/feed-items.tsx | 328 +++++++++++++++++------- web/components/outcome-label.tsx | 34 ++- web/pages/[username]/[contractSlug].tsx | 2 +- 4 files changed, 308 insertions(+), 147 deletions(-) diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts index a12c4e0a..7879b637 100644 --- a/web/components/feed/activity-items.ts +++ b/web/components/feed/activity-items.ts @@ -31,6 +31,8 @@ type BaseActivityItem = { export type CommentInputItem = BaseActivityItem & { type: 'commentInput' + betsByCurrentUser: Bet[] + comments: Comment[] } export type DescriptionItem = BaseActivityItem & { @@ -48,12 +50,13 @@ export type BetItem = BaseActivityItem & { bet: Bet hideOutcome: boolean smallAvatar: boolean + hideComment?: boolean } export type CommentItem = BaseActivityItem & { type: 'comment' comment: Comment - bet: Bet | undefined + betsBySameUser: Bet[] hideOutcome: boolean truncate: boolean smallAvatar: boolean @@ -129,7 +132,7 @@ function groupBets( type: 'comment' as const, id: bet.id, comment, - bet, + betsBySameUser: [bet], contract, hideOutcome, truncate: abbreviated, @@ -280,7 +283,7 @@ function groupBetsAndComments( id: comment.id, contract: contract, comment, - bet: undefined, + betsBySameUser: [], truncate: abbreviated, hideOutcome: true, smallAvatar, @@ -308,6 +311,27 @@ function groupBetsAndComments( return abbrItems } +function getCommentsWithPositions( + bets: Bet[], + comments: Comment[], + contract: Contract +) { + const betsByUserId = _.groupBy(bets, (bet) => bet.userId) + + const items = comments.map((comment) => ({ + type: 'comment' as const, + id: comment.id, + contract: contract, + comment, + betsBySameUser: bets.length === 0 ? [] : betsByUserId[comment.userId] ?? [], + truncate: true, + hideOutcome: false, + smallAvatar: false, + })) + + return items +} + export function getAllContractActivityItems( contract: Contract, bets: Bet[], @@ -361,6 +385,8 @@ export function getAllContractActivityItems( type: 'commentInput', id: 'commentInput', contract, + betsByCurrentUser: [], + comments: [], }) } else { items.push( @@ -385,6 +411,8 @@ export function getAllContractActivityItems( type: 'commentInput', id: 'commentInput', contract, + betsByCurrentUser: [], + comments: [], }) } @@ -432,24 +460,13 @@ export function getRecentContractActivityItems( ) ) } else { - const onlyUsersBetsOrBetsWithComments = bets.filter((bet) => - comments.some( - (comment) => comment.betId === bet.id || bet.userId === user?.id - ) - ) items.push( - ...groupBetsAndComments( - onlyUsersBetsOrBetsWithComments, - comments, - contract, - user?.id, - { - hideOutcome: false, - abbreviated: true, - smallAvatar: false, - reversed: true, - } - ) + ...groupBetsAndComments(bets, comments, contract, user?.id, { + hideOutcome: false, + abbreviated: true, + smallAvatar: false, + reversed: true, + }) ) } @@ -471,37 +488,29 @@ export function getSpecificContractActivityItems( switch (mode) { case 'bets': items.push( - ...groupBets(bets, comments, contract, user?.id, { + ...bets.map((bet) => ({ + type: 'bet' as const, + id: bet.id, + bet, + contract, hideOutcome: false, - abbreviated: false, smallAvatar: false, - reversed: false, - }) + hideComment: true, + })) ) break case 'comments': - const onlyBetsWithComments = bets.filter((bet) => - comments.some((comment) => comment.betId === bet.id) - ) - items.push( - ...groupBetsAndComments( - onlyBetsWithComments, - comments, - contract, - user?.id, - { - hideOutcome: false, - abbreviated: false, - smallAvatar: false, - reversed: false, - } - ) - ) + items.push(...getCommentsWithPositions(bets, comments, contract)) + items.push({ type: 'commentInput', id: 'commentInput', contract, + betsByCurrentUser: user + ? bets.filter((bet) => bet.userId === user.id) + : [], + comments: comments, }) break } diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index 7518c998..584907d9 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -39,7 +39,13 @@ import BetRow from '../bet-row' import { Avatar } from '../avatar' import { Answer } from '../../../common/answer' import { ActivityItem } from './activity-items' -import { FreeResponse, FullContract } from '../../../common/contract' +import { + Binary, + CPMM, + DPM, + FreeResponse, + FullContract, +} from '../../../common/contract' import { BuyButton } from '../yes-no-selector' import { getDpmOutcomeProbability } from '../../../common/calculate-dpm' import { AnswerBetPanel } from '../answers/answer-bet-panel' @@ -50,6 +56,7 @@ import { trackClick } from '../../lib/firebase/tracking' import { firebaseLogin } from '../../lib/firebase/users' import { DAY_MS } from '../../../common/util/time' import NewContractBadge from '../new-contract-badge' +import { calculateCpmmSale } from '../../../common/calculate-cpmm' export function FeedItems(props: { contract: Contract @@ -123,21 +130,38 @@ function FeedItem(props: { item: ActivityItem }) { export function FeedComment(props: { contract: Contract comment: Comment - bet: Bet | undefined + betsBySameUser: Bet[] hideOutcome: boolean truncate: boolean smallAvatar: boolean }) { - const { contract, comment, bet, hideOutcome, truncate, smallAvatar } = props - let money: string | undefined - let outcome: string | undefined - let bought: string | undefined - if (bet) { - outcome = bet.outcome - bought = bet.amount >= 0 ? 'bought' : 'sold' - money = formatMoney(Math.abs(bet.amount)) - } + const { + contract, + comment, + betsBySameUser, + hideOutcome, + truncate, + smallAvatar, + } = props const { text, userUsername, userName, userAvatarUrl, createdTime } = comment + let outcome: string | undefined, + bought: string | undefined, + money: string | undefined + + const matchedBet = betsBySameUser.find((bet) => bet.id === comment.betId) + if (matchedBet) { + outcome = matchedBet.outcome + bought = matchedBet.amount >= 0 ? 'bought' : 'sold' + money = formatMoney(Math.abs(matchedBet.amount)) + } + + // Only calculated if they don't have a matching bet + const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } = + getBettorsPosition( + contract, + comment.createdTime, + matchedBet ? [] : betsBySameUser + ) return ( <> @@ -155,18 +179,33 @@ export function FeedComment(props: { username={userUsername} name={userName} />{' '} - {bought} {money} - {!hideOutcome && ( + {!matchedBet && userPosition > 0 && ( <> - {' '} - of{' '} - <OutcomeLabel - outcome={outcome ? outcome : ''} - contract={contract} - truncate="short" - /> + {'with ' + userPositionMoney + ' '} + <> + {' of '} + <OutcomeLabel + outcome={yesFloorShares > noFloorShares ? 'YES' : 'NO'} + contract={contract} + truncate="short" + /> + </> </> )} + <> + {bought} {money} + {outcome && !hideOutcome && ( + <> + {' '} + of{' '} + <OutcomeLabel + outcome={outcome ? outcome : ''} + contract={contract} + truncate="short" + /> + </> + )} + </> <RelativeTimestamp time={createdTime} /> </p> </div> @@ -180,20 +219,12 @@ export function FeedComment(props: { ) } -function RelativeTimestamp(props: { time: number }) { - const { time } = props - return ( - <DateTimeTooltip time={time}> - <span className="ml-1 whitespace-nowrap text-gray-400"> - {fromNow(time)} - </span> - </DateTimeTooltip> - ) -} - -export function CommentInput(props: { contract: Contract }) { - // see if we can comment input on any bet: - const { contract } = props +export function CommentInput(props: { + contract: Contract + betsByCurrentUser: Bet[] + comments: Comment[] +}) { + const { contract, betsByCurrentUser, comments } = props const user = useUser() const [comment, setComment] = useState('') @@ -206,14 +237,50 @@ export function CommentInput(props: { contract: Contract }) { setComment('') } + // Should this be oldest bet or most recent bet? + const mostRecentCommentableBet = betsByCurrentUser + .filter( + (bet) => + canCommentOnBet(bet.userId, bet.createdTime, user) && + !comments.some((comment) => comment.betId == bet.id) + ) + .sort((b1, b2) => b1.createdTime - b2.createdTime) + .pop() + + if (mostRecentCommentableBet) { + return ( + <FeedBet + contract={contract} + bet={mostRecentCommentableBet} + hideOutcome={false} + smallAvatar={false} + /> + ) + } + const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } = + getBettorsPosition(contract, Date.now(), betsByCurrentUser) + return ( <> - <Row className={'flex w-full gap-2 pt-5'}> + <Row className={'flex w-full gap-2 pt-3'}> <div> <Avatar avatarUrl={user?.avatarUrl} username={user?.username} /> </div> <div className={'min-w-0 flex-1 py-1.5'}> <div className="text-sm text-gray-500"> + {user && userPosition > 0 && ( + <> + {'You with ' + userPositionMoney + ' '} + <> + {' of '} + <OutcomeLabel + outcome={yesFloorShares > noFloorShares ? 'YES' : 'NO'} + contract={contract} + truncate="short" + /> + </> + </> + )} <div className="mt-2"> <Textarea value={comment} @@ -244,18 +311,76 @@ export function CommentInput(props: { contract: Contract }) { ) } +function RelativeTimestamp(props: { time: number }) { + const { time } = props + return ( + <DateTimeTooltip time={time}> + <span className="ml-1 whitespace-nowrap text-gray-400"> + {fromNow(time)} + </span> + </DateTimeTooltip> + ) +} + +function getBettorsPosition( + contract: Contract, + createdTime: number, + bets: Bet[] +) { + let yesFloorShares = 0, + yesShares = 0, + noShares = 0, + noFloorShares = 0 + + const emptyReturn = { + userPosition: 0, + userPositionMoney: 0, + yesFloorShares, + noFloorShares, + } + + // TODO: show which of the answers was their majority stake at time of comment for FR? + if (contract.outcomeType != 'BINARY') { + return emptyReturn + } + if (bets.length === 0) { + return emptyReturn + } + + // Calculate the majority shares they had when they made the comment + const betsBefore = bets.filter((prevBet) => prevBet.createdTime < createdTime) + const [yesBets, noBets] = _.partition( + betsBefore ?? [], + (bet) => bet.outcome === 'YES' + ) + yesShares = _.sumBy(yesBets, (bet) => bet.shares) + noShares = _.sumBy(noBets, (bet) => bet.shares) + yesFloorShares = Math.floor(yesShares) + noFloorShares = Math.floor(noShares) + + const userPosition = yesFloorShares || noFloorShares + const { saleValue } = calculateCpmmSale( + contract as FullContract<CPMM, Binary>, + yesShares || noShares, + yesFloorShares > noFloorShares ? 'YES' : 'NO' + ) + const userPositionMoney = formatMoney(Math.abs(saleValue)) + return { userPosition, userPositionMoney, yesFloorShares, noFloorShares } +} + export function FeedBet(props: { contract: Contract bet: Bet hideOutcome: boolean smallAvatar: boolean + hideComment?: boolean bettor?: User // If set: reveal bettor identity }) { - const { contract, bet, hideOutcome, smallAvatar, bettor } = props + const { contract, bet, hideOutcome, smallAvatar, bettor, hideComment } = props const { id, amount, outcome, createdTime, userId } = bet const user = useUser() const isSelf = user?.id === userId - const canComment = canCommentOnBet(userId, createdTime, user) + const canComment = canCommentOnBet(userId, createdTime, user) && !hideComment const [comment, setComment] = useState('') async function submitComment() { @@ -268,71 +393,76 @@ export function FeedBet(props: { return ( <> - <div> - {isSelf ? ( - <Avatar - className={clsx(smallAvatar && 'ml-1')} - size={smallAvatar ? 'sm' : undefined} - avatarUrl={user.avatarUrl} - username={user.username} - /> - ) : bettor ? ( - <Avatar - className={clsx(smallAvatar && 'ml-1')} - size={smallAvatar ? 'sm' : undefined} - avatarUrl={bettor.avatarUrl} - username={bettor.username} - /> - ) : ( - <div className="relative px-1"> - <div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200"> - <UserIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> - </div> - </div> - )} - </div> - <div className={'min-w-0 flex-1 py-1.5'}> - <div className="text-sm text-gray-500"> - <span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span>{' '} - {bought} {money} - {!hideOutcome && ( - <> - {' '} - of{' '} - <OutcomeLabel - outcome={outcome} - contract={contract} - truncate="short" - /> - </> - )} - <RelativeTimestamp time={createdTime} /> - {(canComment || comment) && ( - <div className="mt-2"> - <Textarea - value={comment} - onChange={(e) => setComment(e.target.value)} - className="textarea textarea-bordered w-full resize-none" - placeholder="Add a comment..." - rows={3} - maxLength={MAX_COMMENT_LENGTH} - onKeyDown={(e) => { - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - submitComment() - } - }} - /> - <button - className="btn btn-outline btn-sm text-transform: mt-1 capitalize" - onClick={submitComment} - disabled={!canComment} - > - Comment - </button> + <Row className={'flex w-full gap-2 pt-3'}> + <div> + {isSelf ? ( + <Avatar + className={clsx(smallAvatar && 'ml-1')} + size={smallAvatar ? 'sm' : undefined} + avatarUrl={user.avatarUrl} + username={user.username} + /> + ) : bettor ? ( + <Avatar + className={clsx(smallAvatar && 'ml-1')} + size={smallAvatar ? 'sm' : undefined} + avatarUrl={bettor.avatarUrl} + username={bettor.username} + /> + ) : ( + <div className="relative px-1"> + <div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200"> + <UserIcon + className="h-5 w-5 text-gray-500" + aria-hidden="true" + /> + </div> </div> )} </div> - </div> + <div className={'min-w-0 flex-1 py-1.5'}> + <div className="text-sm text-gray-500"> + <span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span>{' '} + {bought} {money} + {!hideOutcome && ( + <> + {' '} + of{' '} + <OutcomeLabel + outcome={outcome} + contract={contract} + truncate="short" + /> + </> + )} + <RelativeTimestamp time={createdTime} /> + {(canComment || comment) && ( + <div className="mt-2"> + <Textarea + value={comment} + onChange={(e) => setComment(e.target.value)} + className="textarea textarea-bordered w-full resize-none" + placeholder="Add a comment..." + rows={3} + maxLength={MAX_COMMENT_LENGTH} + onKeyDown={(e) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + submitComment() + } + }} + /> + <button + className="btn btn-outline btn-sm text-transform: mt-1 capitalize" + onClick={submitComment} + disabled={!canComment} + > + Comment + </button> + </div> + )} + </div> + </div> + </Row> </> ) } diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index 0901dd4e..b2bc2f03 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -1,4 +1,3 @@ -import clsx from 'clsx' import { Answer } from '../../common/answer' import { getProbability } from '../../common/calculate' import { @@ -11,6 +10,7 @@ import { FullContract, } from '../../common/contract' import { formatPercent } from '../../common/util/format' +import { ClientRender } from './client-render' export function OutcomeLabel(props: { contract: Contract @@ -72,11 +72,13 @@ export function FreeResponseOutcomeLabel(props: { const chosen = answers?.find((answer) => answer.id === resolution) if (!chosen) return <AnswerNumberLabel number={resolution} /> return ( - <AnswerLabel - answer={chosen} - truncate={truncate} - className={answerClassName} - /> + <FreeResponseAnswerToolTip text={chosen.text}> + <AnswerLabel + answer={chosen} + truncate={truncate} + className={answerClassName} + /> + </FreeResponseAnswerToolTip> ) } @@ -126,3 +128,23 @@ export function AnswerLabel(props: { return <span className={className}>{truncated}</span> } + +function FreeResponseAnswerToolTip(props: { + text: string + children?: React.ReactNode +}) { + const { text } = props + return ( + <> + <ClientRender> + <span + className="tooltip hidden cursor-default sm:inline-block" + data-tip={text} + > + {props.children} + </span> + </ClientRender> + <span className="whitespace-nowrap sm:hidden">{props.children}</span> + </> + ) +} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 6e6e4523..29786703 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -287,7 +287,7 @@ function ContractTopTrades(props: { <FeedComment contract={contract} comment={commentsById[topCommentId]} - bet={betsById[topCommentId]} + betsBySameUser={[betsById[topCommentId]]} hideOutcome={false} truncate={false} smallAvatar={false} From 73fc67955d279a4d729bb295e2f4e8309ea82755 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 29 Apr 2022 16:35:56 -0700 Subject: [PATCH 10/68] Send M$ to Charity & txns (#81) * Add components for CPM landing and charity pages * Remove misc.ts to fix build * Set up cloud function for writing txns * More plumbing for txns * Fix up API call * Use Date.now() to keep timestamps simple * Some styles for charity list page * Hard code charities data * Pass charity data to charity page * Update txn type * Listen for charity txns * Handle txn to non-user by burning it * Read txns for charity card and charity page. * Set images to object contain * Clean up txn types * Move pic to top of card. Other misc styling. * Update charity short & long descriptions * Add `token` and `category` to Txn * Fix breakages * Show Charity link in the sidebar * Fix typing issues * Fix not reading from the right type * Switch out icon * Also show Charity icon on mobile * Update copy Co-authored-by: Austin Chen <akrolsmir@gmail.com> Co-authored-by: James Grugett <jahooma@gmail.com> --- common/charity.ts | 282 ++++++++++++++++++++++++ common/txn.ts | 21 ++ firestore.rules | 5 + functions/src/backup-db.ts | 1 + functions/src/index.ts | 1 + functions/src/transact.ts | 82 +++++++ web/components/charity/charity-card.tsx | 34 +++ web/components/nav/nav-bar.tsx | 7 +- web/components/nav/sidebar.tsx | 7 +- web/hooks/use-charity-txns.ts | 13 ++ web/lib/firebase/api-call.ts | 6 + web/lib/firebase/txns.ts | 23 ++ web/package.json | 1 + web/pages/charity/[charitySlug].tsx | 212 ++++++++++++++++++ web/pages/charity/index.tsx | 64 ++++++ web/tailwind.config.js | 1 + yarn.lock | 5 + 17 files changed, 761 insertions(+), 4 deletions(-) create mode 100644 common/charity.ts create mode 100644 common/txn.ts create mode 100644 functions/src/transact.ts create mode 100644 web/components/charity/charity-card.tsx create mode 100644 web/hooks/use-charity-txns.ts create mode 100644 web/lib/firebase/txns.ts create mode 100644 web/pages/charity/[charitySlug].tsx create mode 100644 web/pages/charity/index.tsx diff --git a/common/charity.ts b/common/charity.ts new file mode 100644 index 00000000..a4cfd551 --- /dev/null +++ b/common/charity.ts @@ -0,0 +1,282 @@ +export interface Charity { + id: string + slug: string + name: string + website: string + ein: string + photo?: string + preview: string + description: string +} + +export const charities: Charity[] = [ + { + name: 'Faunalytics', + website: 'https://faunalytics.org/', + ein: '01-0686889', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2016/08/logo-faunalytics2400x2400-200x200@2x.jpg', + preview: + 'Faunalytics conducts research and shares knowledge to help advocates help animals effectively.', + description: + "Faunalytics' mission is to empower animal advocates with access to research, analysis, strategies, and messages that maximize their effectiveness to reduce animal suffering.\n Animals need you, and you need data. We conduct essential research, maintain an online research library, and directly support advocates and organizations in their work to save lives. The range of data we offer helps our movement understand how people think about and respond to advocacy, providing advocates with the best strategies to inspire change for animals. ", + }, + { + name: 'The Humane League', + website: 'https://thehumaneleague.org/', + ein: '04-3817491', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2019/03/thl-mended-heart-logo@2x-200x200@2x.jpg', + preview: + 'We exist to end the abuse of animals raised for food by influencing the policies of the world’s biggest companies, demanding legislation, and empowering others to take action and leave animals off their plates', + description: + 'The Humane League (THL) currently operates in the U.S., Mexico, the U.K., and Japan, where they work to improve animal welfare standards through grassroots campaigns, movement building, veg*n advocacy, research, and advocacy training, as well as through corporate, media, and community outreach. They work to build the animal advocacy movement internationally through the Open Wing Alliance (OWA), a coalition founded by THL whose mission is to end the use of battery cages globally.', + }, + { + name: 'Wild Animal Initiative', + website: 'https://www.wildanimalinitiative.org/', + ein: '82-2281466', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2020/11/WAI-logo_square-gray-on-teal-1-630x630.png', + preview: 'We want to make life better for wild animals.', + description: + 'Wild Animal Initiative (WAI) currently operates in the U.S., where they work to strengthen the animal advocacy movement through creating an academic field dedicated to wild animal welfare. They compile literature reviews, write theoretical and opinion articles, and publish research results on their website and/or in peer-reviewed journals. WAI focuses on identifying and sharing possible research avenues and connecting with more established fields. They also work with researchers from various academic and non-academic institutions to identify potential collaborators, and they recently launched a grant assistance program.', + }, + { + name: 'Give Directly', + website: 'https://www.givedirectly.org/', + ein: '27-1661997', + photo: + 'https://www.givewell.org/sites/default/files/charity_logos/GiveDirectly.jpg', + preview: 'Send money directly to people living in poverty.', + description: + 'GiveDirectly is a nonprofit that lets donors like you send money directly to the world’s poorest households. We believe people living in poverty deserve the dignity to choose for themselves how best to improve their lives — cash enables that choice. Since 2009, we’ve delivered $500M+ in cash directly into the hands of over 1 million families living in poverty. We currently have operations in Kenya, Rwanda, Liberia, Malawi, Morocco, Mozambique, DRC, Uganda, and the United States.', + }, + { + name: 'Hellen Keller International', + website: 'https://www.hki.org/', + ein: '13-5562162', + photo: + 'https://www.ntd-ngonetwork.org/sites/nnn/files/content/organisation/logos/2020-01-28/v2_HKLogo_Primary_RGB.jpg', + preview: + 'At Helen Keller Intl, we envision a world where no one is deprived of the opportunity to live a healthy life – and reach their true potential.', + description: + 'Right now, 36 million people worldwide — most of them in developing countries — are blind.\n 90 percent of them didn’t have to lose their sight. Helen Keller International is dedicated to combating the causes and consequences of vision loss and making clear vision a reality for those most vulnerable to disease and who lack access to quality eye care.\n Last year alone, we helped provide many tens of millions of people with treatment to prevent diseases of poverty including blinding trachoma and river blindness.\n Surgeons trained by our staff also performed tens of thousands of cataract surgeries in the developing world.  And in the United States, we screened the vision of nearly 66,000 students living in some of our country’s poorest neighborhoods and provided free eyeglasses to just over 16,000 of them. ', + }, + { + name: 'Against Malaria Foundation', + website: 'https://www.againstmalaria.com/', + ein: '20-3069841', + photo: + 'https://media-exp1.licdn.com/dms/image/C4D0BAQFvdcum9KBNfg/company-logo_200_200/0?e=2159024400&v=beta&t=hxjJCKQkMp2irTOcuJEceW7x4l3c4PD7gYCQ6ulgYlg', + preview: 'We help protect people from malaria.', + description: + 'AMF (againstmalaria.com) provides funding for long-lasting insecticide-treated net (LLIN) distributions (for protection against malaria) in developing countries. There is strong evidence that distributing LLINs reduces child mortality and malaria cases. AMF conducts post-distribution surveys of completed distributions to determine whether LLINs have reached their intended destinations and how long they remain in good condition.', + }, + { + name: 'Malaria Consortium', + website: 'https://www.malariaconsortium.org/', + ein: '98-0627052', + photo: + 'http://www.malariaconsortium.org/website-2013/images_template/malaria_consortium_logo.png', + preview: + 'We specialise in the prevention, control and treatment of malaria and other communicable diseases.', + description: + 'We are dedicated to ensuring our work is supported by strong evidence and remains grounded in the lessons we learn through implementation. We explore beyond current practice, to try out innovative ways – through research, implementation and policy development – to achieve effective and sustainable disease management and control.', + }, + { + name: 'New Incentives', + website: 'https://www.newincentives.org/', + ein: '45-2368993', + photo: + 'https://uploads-ssl.webflow.com/5f7c51bf9fac9b5ed62aa37b/5f7c51bf9fac9b85c42aa3df_Group%20344%20(1).svg', + preview: 'Cash incentives to boost vaccination rates and save lives.', + description: + 'New Incentives (newincentives.org) runs a conditional cash transfer (CCT) program in North West Nigeria which seeks to increase uptake of routine immunizations through cash transfers, raising public awareness of the benefits of vaccination and reducing the frequency of vaccine stockouts.', + }, + { + name: 'SCI foundation', + website: 'https://schistosomiasiscontrolinitiative.org/', + ein: '', + photo: + 'https://images.easyfundraising.org.uk/cause/cropped/cause-logo-e99e0632a8a9572150fdcf3abf08ad45.png', + preview: + 'SCI works with governments in sub-Saharan Africa to create or scale up programs that treat schistosomiasis and soil-transmitted helminthiasis ("deworming").', + description: + 'We’re a non-profit initiative supporting governments in sub-Saharan African countries. We support them to develop sustainable, cost-effective programmes against parasitic worm infections such as schistosomiasis and intestinal worms.  Since our foundation in 2002, we’ve contributed to the delivery of over 200 million treatments against these diseases. The programmes are highly effective; parasitic worm infections can be reduced by up to 60% after just one round of treatment.', + }, + { + name: 'Wikimedia Foundation', + website: 'https://wikimediafoundation.org/', + ein: '20-0049703', + photo: + 'http://2.bp.blogspot.com/-jVseU39DW0s/VjmXVMOEEEI/AAAAAAAACK8/dwUP6sLqy-Q/s1600/wikimedia.png', + preview: 'We help everyone share in the sum of all knowledge.', + description: + 'We are the people who keep knowledge free. There is an amazing community of people around the world that makes great projects like Wikipedia. We help them do that work. We take care of the technical infrastructure, the legal challenges, and the growing pains.', + }, + { + name: 'Rainforest Trust', + website: 'https://www.rainforesttrust.org/', + ein: '13-3500609', + photo: + 'http://ww1.prweb.com/prfiles/2019/05/29/16344590/Rrainforest%20Trust%20new%20logo%20tall-1%20copy.png', + preview: + 'Rainforest Trust saves endangered wildlife and protects our planet by creating rainforest reserves through partnerships, community engagement and donor support.', + description: + 'Our unique, cost-effective conservation model for protecting endangered species has been implemented successfully for over 30 years. Thanks to the generosity of our donors, the expertise of our partners and the participation of local communities across the tropics, our reserves are exemplary models of international conservation.', + }, + { + name: 'The Nature Conservancy', + website: 'https://www.nature.org/en-us/', + ein: '53-0242652', + photo: + 'https://mma.prnewswire.com/media/1140905/The_Nature_Conservancy_Logo.jpg?p=facebook', + preview: 'A Future Where People and Nature Thrive', + description: + 'The Nature Conservancy is a global environmental nonprofit working to create a world where people and nature can thrive. Founded in the U.S. through grassroots action in 1951, The Nature Conservancy has grown to become one of the most effective and wide-reaching environmental organizations in the world. Thanks to more than a million members and the dedicated efforts of our diverse staff and over 400 scientists, we impact conservation in 76 countries and territories: 37 by direct conservation impact and 39 through partners.', + }, + { + name: 'Doctors Without Borders', + website: 'https://www.doctorswithoutborders.org/', + ein: '13-3433452', + photo: 'https://www.doctorswithoutborders.org/themes/custom/msf/logo.svg', + preview: + 'We provide independent, impartial medical humanitarian assistance to the people who need it most.', + description: + 'Doctors Without Borders/Médecins Sans Frontières (MSF) cares for people affected by conflict, disease outbreaks, natural and human-made disasters, and exclusion from health care in more than 70 countries.', + }, + { + name: 'World Wildlife Fund', + website: 'https://www.worldwildlife.org/', + ein: '52-1693387', + photo: + 'https://www.worldwildlife.org/assets/structure/unique/logo-c562409bb6158bf64e5f8b1be066dbd5983d75f5ce7c9935a5afffbcc03f8e5d.png', + preview: + 'WWF works to sustain the natural world for the benefit of people and wildlife, collaborating with partners from local to global levels in nearly 100 countries.', + description: + 'As the world’s leading conservation organization, WWF works in nearly 100 countries to tackle the most pressing issues at the intersection of nature, people, and climate. We collaborate with local communities to conserve the natural resources we all depend on and build a future in which people and nature thrive. Together with partners at all levels, we transform markets and policies toward sustainability, tackle the threats driving the climate crisis, and protect and restore wildlife and their habitats.', + }, + { + name: 'UNICEF USA', + website: 'https://www.unicefusa.org/', + ein: '13-1760110', + preview: + "UNICEF USA helps save and protect the world's most vulnerable children.", + description: + 'Over eight decades, the United Nations Children’s Fund (UNICEF) has built an unprecedented global support system for the world’s children. UNICEF relentlessly works day in and day out to deliver the essentials that give every child an equitable chance in life: health care and immunizations, safe water and sanitation, nutrition, education, emergency relief and more. UNICEF USA advances the global mission of UNICEF by rallying the American public to support the world’s most vulnerable children. Together, we have helped save more children’s lives than any other humanitarian organization.', + }, + { + name: 'Vitamin Angels', + website: 'https://www.vitaminangels.org/', + ein: '77-0485881', + photo: + 'https://www.newhope.com/sites/newhope360.com/files/styles/article_featured_retina/public/vitamin-angels-logo.jpg?itok=pfNCPLE0', + preview: + 'By improving access to vital nutrition, everyone gets an equal chance to grow, thrive, and prosper.', + description: + 'Our team of program experts collaborates with thousands of local organizations and national governments around the world, focusing efforts on reaching communities who are underserved. Vitamin Angels’ program partners are a local presence in these communities. As trusted organizations already hard at work, they connect millions of pregnant women and young children with our evidence-based nutrition interventions in addition to the health services they already provide.', + }, + { + name: 'Free Software Foundation', + website: 'https://www.fsf.org/', + ein: '04-2888848', + photo: 'https://www.gnu.org/graphics/logo-fsf.org.png', + preview: + 'The Free Software Foundation (FSF) is a nonprofit with a worldwide mission to promote computer user freedom.', + description: + 'As our society grows more dependent on computers, the software we run is of critical importance to securing the future of a free society. Free software is about having control over the technology we use in our homes, schools and businesses, where computers work for our individual and communal benefit, not for proprietary software companies or governments who might seek to restrict and monitor us. The Free Software Foundation exclusively uses free software to perform its work.The Free Software Foundation is working to secure freedom for computerusers by promoting the development and use of free (as in freedom) software and documentation—particularly the GNU operating system—and by campaigning against threats to computer user freedom like Digital Restrictions Management (DRM) and software patents.', + }, + { + name: 'Direct Relief', + website: 'https://www.directrelief.org/', + ein: '95-1831116', + photo: + 'https://www.ngoadvisor.net/wp-content/uploads/2016/02/DirectRelief_Logo_RGB-2-1920x576.png', + preview: + 'Direct Relief is a humanitarian aid organization, active in all 50 states and more than 80 countries, with a mission to improve the health and lives of people affected by poverty or emergencies – without regard to politics, religion, or ability to pay.', + description: + 'Nongovernmental, nonsectarian, and not-for-profit, Direct Relief relies entirely on private contributions to advance its mission and perform a wide range of functions.\n Included among them are identifying key local providers of health services; working to identify the unmet needs of people in the low-resource areas; mobilizing essential medicines, supplies, and equipment that are requested and appropriate for the circumstances; and managing the many details inherent in storing, transporting, and distributing such resources to organizations in the most efficient manner possible.', + }, + { + name: 'World Resources Institute', + website: 'https://www.wri.org/', + ein: '52-1257057', + photo: + 'https://www.americansecurityproject.org/wp-content/uploads/2016/11/WRI_logo_4c.png', + preview: + 'WRI is a global nonprofit organization that works with leaders in government, business and civil society to research, design, and carry out practical solutions that simultaneously improve people’s lives and ensure nature can thrive.', + description: + "Since its founding in 1982, WRI has been guided by its mission and core values which are integrated into all that we do. Our mission: To move human society to live in ways that protect Earth’s environment and its capacity to provide for the needs and aspirations of current and future generations. WRI relies on the generosity of our donors to drive outcomes that help the world to be a fairer, healthier and more sustainable place for people and the planet. We publish our financials annually to highlight our continued fiscal accountability. That's why WRI consistently receives top ratings from charity evaluators for our strong financial stewardship and commitment to transparency and accountability.", + }, + { + name: 'ProPublica', + website: 'https://www.propublica.org/', + ein: '14-2007220', + photo: + 'https://seekvectorlogo.com/wp-content/uploads/2018/09/propublica-vector-logo.png', + preview: + 'The mission: to expose abuses of power and betrayals of the public trust by government, business, and other institutions, using the moral force of investigative journalism to spur reform through the sustained spotlighting of wrongdoing.', + description: + 'ProPublica is an independent, nonprofit newsroom that produces investigative journalism with moral force. We dig deep into important issues, shining a light on abuses of power and betrayals of public trust — and we stick with those issues as long as it takes to hold power to account. With a team of more than 100 dedicated journalists, ProPublica covers a range of topics including government and politics, business, criminal justice, the environment, education, health care, immigration, and technology. We focus on stories with the potential to spur real-world impact. Among other positive changes, our reporting has contributed to the passage of new laws; reversals of harmful policies and practices; and accountability for leaders at local, state and national levels.', + }, + { + name: 'Dana-Farber Cancer Institute', + website: 'https://www.dana-farber.org/', + ein: '04-2263040', + photo: + 'https://www.danafarbermasterclass.com/assets/images/DFCI-logo-lens-stacked.png', + preview: + "For over 70 years, we've led the world by making life-changing breakthroughs in cancer research and patient care, providing the most advanced treatments available.", + description: + "Since its founding in 1947, Dana-Farber Cancer Institute in Boston, Massachusetts has been committed to providing adults and children with cancer with the best treatment available today while developing tomorrow's cures through cutting-edge research. Today, the Institute employs more than 5,000 staff, faculty, and clinicians supporting more than 640,000 annual outpatient visits, more than 1,000 hospital discharges per year, and has over 1,100 open clinical trials. Dana-Farber is internationally renowned for its equal commitment to cutting edge research and provision of excellent patient care. The deep expertise in these two areas uniquely positions Dana-Farber to develop, test, and gain FDA approval for new cancer therapies in its laboratories and clinical settings. Dana-Farber researchers have contributed to the development of 35 of 75 cancer drugs recently approved by the FDA for use in cancer patients.", + }, + { + name: 'Save The Children', + website: 'https://www.savethechildren.org/', + ein: '06-0726487', + photo: + 'https://www.thisisclapham.co.uk/wp-content/uploads/2016/08/savethechildren.png', + preview: + 'Through the decades, Save the Children has continued to work to save children’s lives, and that’s still what we do today.', + description: + "Our pioneering programs address children's unique needs, giving them a healthy start in life, the opportunity to learn and protection from harm. In the United States and around the world, our work creates lasting change for children, their families and communities – ultimately, transforming the future we all share.\nThis work is only made possible by the ongoing generosity of our donors, whose valuable support is used in the most cost-effective ways. It's important to note that all our work intersects – helping a boy or girl go to school also protects them from dangers such as child trafficking and early marriage. Keeping children healthy from disease or malnutrition means their parents are more likely to avoid costly treatment and be better able to provide for their family.\nWe don’t go into communities, carry out a project and then move on. We consult with children, their families, community leaders and local councils to understand all the issues or barriers, and then we develop programs that address these. We build trust so that our programs are successful and bring about real change.", + }, + { + name: 'World Central Kitchen Incorporated', + website: 'https://wck.org/', + ein: '27-3521132', + photo: + 'https://res.cloudinary.com/dktp1ybbx/image/upload/f_auto,fl_lossy,q_auto/v1560203222/organization/prod/924457/M0oxO9vaxO.png', + preview: + 'WCK is first to the frontlines, providing meals in response to humanitarian, climate, and community crises. We build resilient food systems with locally led solutions.', + description: + "WCK responds to natural disasters, man-made crises, and humanitarian emergencies around the world. We're a team of food first responders, mobilizing with the urgency of now to get meals to the people who need them most. Deploying our model of quick action, leveraging local resources, and adapting in real time, we know that a nourishing meal in a time of crisis is so much more than a plate of food—it's hope, it's dignity, and it's a sign that someone cares.", + }, + { + name: 'The Johns Hopkins Center for Health Security', + website: 'https://www.centerforhealthsecurity.org/', + ein: '', + photo: + 'https://www.centerforhealthsecurity.org/sebin/d/d/CHS.logo.horizontal.blue.png', + preview: + 'Our mission: to protect people’s health from epidemics and disasters and ensure that communities are resilient to major challenges.', + description: + 'The Center for Health Security undertakes a series of projects, collaborations, and initiatives to push forward progress on global health security, emerging infectious diseases and epidemics, medical and public health preparedness and response, deliberate biological threats, and opportunities and risks in the life sciences. We:\n- Conduct research and analysis on major domestic and international health security issues.\n- Engage with researchers, the policymaking community, and the private sector to make progress in the field.\n- Convene expert working groups, congressional seminars, scientific meetings, conferences, and tabletop exercises to stimulate new thinking and provoke action.\n- Educate a rising generation of scholars, practitioners, and policymakers.', + }, + { + name: 'ALLFED', + website: 'https://allfed.info/', + ein: '27-6601178', + preview: 'Feeding everyone no matter what.', + description: + 'The mission of the Alliance to Feed the Earth in Disasters is to help create resilience to global food shocks. We seek to identify various resilient food solutions and to help governments implement these solutions, to increase the chances that people have enough to eat in the event of a global catastrophe. We focus on events that could deplete food supplies or access to 5% of the global population or more.Our ultimate goal is to feed everyone, no matter what. An important aspect of this goal is that we need to establish equitable solutions so that all people can access the nutrition they need, regardless of wealth or location.ALLFED is inspired by effective altruism, using reason and evidence to identify how to do the most good. Our solutions are backed by science and research, and we also identify the most cost-effective solutions, to be able to provide more nutrition in catastrophes.', + }, +].map((charity) => { + const slug = charity.name.toLowerCase().replace(/\s/g, '-') + return { + ...charity, + id: slug, + slug, + } +}) diff --git a/common/txn.ts b/common/txn.ts new file mode 100644 index 00000000..8beea234 --- /dev/null +++ b/common/txn.ts @@ -0,0 +1,21 @@ +// A txn (pronounced "texan") respresents a payment between two ids on Manifold +// Shortened from "transaction" to distinguish from Firebase transactions (and save chars) +export type Txn = { + id: string + createdTime: number + + fromId: string + fromType: SourceType + + toId: string + toType: SourceType + + amount: number + token: 'M$' // | 'USD' | MarketOutcome + + category: 'CHARITY' // | 'BET' | 'TIP' + // Human-readable description + description?: string +} + +export type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' diff --git a/firestore.rules b/firestore.rules index 48214e3b..65783ba1 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1,5 +1,6 @@ rules_version = '2'; +// To pick the right project: `firebase projects:list`, then `firebase use <project-name>` // To deploy: `firebase deploy --only firestore:rules` service cloud.firestore { match /databases/{database}/documents { @@ -76,5 +77,9 @@ service cloud.firestore { allow create, update: if request.auth.uid == userId && request.resource.data.userId == userId; allow delete: if request.auth.uid == userId; } + + match /txns/{txnId} { + allow read; + } } } diff --git a/functions/src/backup-db.ts b/functions/src/backup-db.ts index bdafbf98..e840b71a 100644 --- a/functions/src/backup-db.ts +++ b/functions/src/backup-db.ts @@ -46,6 +46,7 @@ export const backupDb = functions.pubsub 'comments', 'followers', 'answers', + 'txns', ], }) .then((responses) => { diff --git a/functions/src/index.ts b/functions/src/index.ts index 19a4a054..f8aa50e3 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -3,6 +3,7 @@ import * as admin from 'firebase-admin' admin.initializeApp() // export * from './keep-awake' +export * from './transact' export * from './place-bet' export * from './resolve-market' export * from './stripe' diff --git a/functions/src/transact.ts b/functions/src/transact.ts new file mode 100644 index 00000000..04e58568 --- /dev/null +++ b/functions/src/transact.ts @@ -0,0 +1,82 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { User } from '../../common/user' +import { Txn } from '../../common/txn' +import { removeUndefinedProps } from '../../common/util/object' + +export const transact = functions + .runWith({ minInstances: 1 }) + .https.onCall(async (data: Omit<Txn, 'id' | 'createdTime'>, context) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + const { amount, fromType, fromId, toId, toType, description } = data + + if (fromType !== 'USER') + return { + status: 'error', + message: "From type is only implemented for type 'user'.", + } + + if (fromId !== userId) + return { + status: 'error', + message: 'Must be authenticated with userId equal to specified fromId.', + } + + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) + return { status: 'error', message: 'Invalid amount' } + + // Run as transaction to prevent race conditions. + return await firestore.runTransaction(async (transaction) => { + const fromDoc = firestore.doc(`users/${userId}`) + const fromSnap = await transaction.get(fromDoc) + if (!fromSnap.exists) { + return { status: 'error', message: 'User not found' } + } + const fromUser = fromSnap.data() as User + + if (fromUser.balance < amount) { + return { + status: 'error', + message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `, + } + } + + if (toType === 'USER') { + const toDoc = firestore.doc(`users/${toId}`) + const toSnap = await transaction.get(toDoc) + if (!toSnap.exists) { + return { status: 'error', message: 'User not found' } + } + const toUser = toSnap.data() as User + transaction.update(toDoc, { balance: toUser.balance + amount }) + } + + const newTxnDoc = firestore.collection(`txns/`).doc() + + const txn: Txn = removeUndefinedProps({ + id: newTxnDoc.id, + createdTime: Date.now(), + + fromId, + fromType, + toId, + toType, + + amount, + // TODO: Unhardcode once we have non-donation txns + token: 'M$', + category: 'CHARITY', + description, + }) + + transaction.create(newTxnDoc, txn) + transaction.update(fromDoc, { balance: fromUser.balance - amount }) + + return { status: 'success', txn } + }) + }) + +const firestore = admin.firestore() diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx new file mode 100644 index 00000000..e50a9fe7 --- /dev/null +++ b/web/components/charity/charity-card.tsx @@ -0,0 +1,34 @@ +import _ from 'lodash' +import Link from 'next/link' +import { Charity } from '../../../common/charity' +import { useCharityTxns } from '../../hooks/use-charity-txns' +import { Row } from '../layout/row' + +export function CharityCard(props: { charity: Charity }) { + const { name, slug, photo, preview, id } = props.charity + + const txns = useCharityTxns(id) + const raised = _.sumBy(txns, (txn) => txn.amount) + + return ( + <Link href={`/charity/${slug}`} passHref> + <div className="card card-compact transition:shadow cursor-pointer border-2 bg-white hover:shadow-md"> + <figure className="h-32"> + {photo ? ( + <img className="h-full w-full object-contain" src={photo} alt="" /> + ) : ( + <div className="h-full w-full bg-gradient-to-r from-slate-300 to-indigo-200" /> + )} + </figure> + <div className="card-body"> + <h3 className="card-title line-clamp-3">{name}</h3> + <div className="line-clamp-4 text-sm">{preview}</div> + <Row className="text-primary mt-4 items-end justify-center gap-1"> + <span className="text-3xl">${Math.floor((raised ?? 0) / 100)}</span> + <span>raised</span> + </Row> + </div> + </div> + </Link> + ) +} diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index fadff13c..a9cb49f0 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -3,8 +3,8 @@ import Link from 'next/link' import { HomeIcon, MenuAlt3Icon, + PresentationChartLineIcon, SearchIcon, - TableIcon, XIcon, } from '@heroicons/react/outline' import { Transition, Dialog } from '@headlessui/react' @@ -39,7 +39,10 @@ export function BottomNavBar() { {user !== null && ( <Link href="/portfolio"> <a className="block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700"> - <TableIcon className="my-1 mx-auto h-6 w-6" aria-hidden="true" /> + <PresentationChartLineIcon + className="my-1 mx-auto h-6 w-6" + aria-hidden="true" + /> Portfolio </a> </Link> diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 576cea87..e3145498 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -5,9 +5,10 @@ import { SearchIcon, ChatIcon, BookOpenIcon, - TableIcon, DotsHorizontalIcon, CashIcon, + HeartIcon, + PresentationChartLineIcon, } from '@heroicons/react/outline' import clsx from 'clsx' import _ from 'lodash' @@ -24,7 +25,8 @@ import { useHasCreatedContractToday } from '../../hooks/use-has-created-contract const navigation = [ { name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Explore', href: '/markets', icon: SearchIcon }, - { name: 'Portfolio', href: '/portfolio', icon: TableIcon }, + { name: 'Portfolio', href: '/portfolio', icon: PresentationChartLineIcon }, + { name: 'Charity', href: '/charity', icon: HeartIcon }, ] const signedOutNavigation = [ @@ -34,6 +36,7 @@ const signedOutNavigation = [ ] const signedOutMobileNavigation = [ + { name: 'Charity', href: '/charity', icon: HeartIcon }, { name: 'Leaderboards', href: '/leaderboards', icon: CakeIcon }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon }, { name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon }, diff --git a/web/hooks/use-charity-txns.ts b/web/hooks/use-charity-txns.ts new file mode 100644 index 00000000..5636e720 --- /dev/null +++ b/web/hooks/use-charity-txns.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react' +import { Txn } from '../../common/txn' +import { listenForCharityTxns } from '../lib/firebase/txns' + +export const useCharityTxns = (charityId: string) => { + const [txns, setTxns] = useState<Txn[]>([]) + + useEffect(() => { + return listenForCharityTxns(charityId, setTxns) + }, [charityId]) + + return txns +} diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 1c5522e7..a71c2752 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -1,5 +1,6 @@ import { httpsCallable } from 'firebase/functions' import { Fold } from '../../../common/fold' +import { Txn } from '../../../common/txn' import { User } from '../../../common/user' import { randomString } from '../../../common/util/random' import './init' @@ -15,6 +16,11 @@ export const createFold = cloudFunction< { status: 'error' | 'success'; message?: string; fold?: Fold } >('createFold') +export const transact = cloudFunction< + Omit<Txn, 'id' | 'createdTime'>, + { status: 'error' | 'success'; message?: string; txn?: Txn } +>('transact') + export const placeBet = cloudFunction('placeBet') export const sellBet = cloudFunction('sellBet') diff --git a/web/lib/firebase/txns.ts b/web/lib/firebase/txns.ts new file mode 100644 index 00000000..8f9a6843 --- /dev/null +++ b/web/lib/firebase/txns.ts @@ -0,0 +1,23 @@ +import { collection, query, where, orderBy } from 'firebase/firestore' +import _ from 'lodash' +import { Txn } from '../../../common/txn' + +import { db } from './init' +import { listenForValues } from './utils' + +const txnCollection = collection(db, 'txns') + +const getCharityQuery = (charityId: string) => + query( + txnCollection, + where('toType', '==', 'CHARITY'), + where('toId', '==', charityId), + orderBy('createdTime', 'desc') + ) + +export function listenForCharityTxns( + charityId: string, + setTxns: (txns: Txn[]) => void +) { + return listenForValues<Txn>(getCharityQuery(charityId), setTxns) +} diff --git a/web/package.json b/web/package.json index 84077a19..0e770f96 100644 --- a/web/package.json +++ b/web/package.json @@ -36,6 +36,7 @@ }, "devDependencies": { "@tailwindcss/forms": "0.4.0", + "@tailwindcss/line-clamp": "^0.3.1", "@tailwindcss/typography": "^0.5.1", "@types/lodash": "4.14.178", "@types/node": "16.11.11", diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx new file mode 100644 index 00000000..e358f4e1 --- /dev/null +++ b/web/pages/charity/[charitySlug].tsx @@ -0,0 +1,212 @@ +import _ from 'lodash' +import clsx from 'clsx' +import { useEffect, useRef, useState } from 'react' +import { Col } from '../../components/layout/col' +import { Row } from '../../components/layout/row' +import { Page } from '../../components/page' +import { Title } from '../../components/title' +import { BuyAmountInput } from '../../components/amount-input' +import { Spacer } from '../../components/layout/spacer' +import { User } from '../../../common/user' +import { useUser } from '../../hooks/use-user' +import { Linkify } from '../../components/linkify' +import { transact } from '../../lib/firebase/api-call' +import { charities, Charity } from '../../../common/charity' +import { useRouter } from 'next/router' +import Custom404 from '../404' +import { useCharityTxns } from '../../hooks/use-charity-txns' + +const manaToUSD = (mana: number) => + (mana / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' }) + +export default function CharityPageWrapper() { + const router = useRouter() + const { charitySlug } = router.query as { charitySlug: string } + + const charity = charities.find((c) => c.slug === charitySlug?.toLowerCase()) + if (!router.isReady) return <></> + if (!charity) { + return <Custom404 /> + } + return <CharityPage charity={charity} /> +} + +function CharityPage(props: { charity: Charity }) { + const { charity } = props + const { name, photo, description } = charity + + // TODO: why not just useUser inside Donation Box rather than passing in? + const user = useUser() + + const txns = useCharityTxns(charity.id) + const totalRaised = _.sumBy(txns, (txn) => txn.amount) + const fromYou = _.sumBy( + txns.filter((txn) => txn.fromId === user?.id), + (txn) => txn.amount + ) + const numSupporters = _.uniqBy(txns, (txn) => txn.fromId).length + + return ( + <Page rightSidebar={<DonationBox user={user} charity={charity} />}> + <Col className="mx-1 w-full items-center sm:px-0"> + <Col className="max-w-2xl rounded bg-white px-8 py-6"> + <Title className="!mt-0" text={name} /> + {/* TODO: donations over time chart */} + <Row className="justify-between"> + {photo && ( + <img + src={photo} + alt="" + className="w-40 rounded-2xl object-contain" + /> + )} + <Details + charity={charity} + totalRaised={totalRaised} + userDonated={fromYou} + numSupporters={numSupporters} + /> + </Row> + <h2 className="mt-7 mb-2 text-xl text-indigo-700">About</h2> + <Blurb text={description} /> + </Col> + </Col> + </Page> + ) +} + +function Blurb({ text }: { text: string }) { + const [open, setOpen] = useState(false) + + // Calculate whether the full blurb is already shown + const ref = useRef<HTMLDivElement>(null) + const [hideExpander, setHideExpander] = useState(false) + useEffect(() => { + if (ref.current) { + setHideExpander(ref.current.scrollHeight <= ref.current.clientHeight) + } + }, []) + + return ( + <> + <div + className={clsx(' text-gray-500', !open && 'line-clamp-5')} + ref={ref} + > + {text} + </div> + <button + onClick={() => setOpen(!open)} + className={clsx( + 'btn btn-link capitalize-none my-3 normal-case text-indigo-700', + hideExpander && 'hidden' + )} + > + {open ? 'Hide' : 'Read more'} + </button> + </> + ) +} + +function Details(props: { + charity: Charity + totalRaised: number + userDonated: number + numSupporters: number +}) { + const { charity, userDonated, numSupporters, totalRaised } = props + const { website } = charity + return ( + <Col className="gap-1 text-right"> + <div className="text-primary mb-2 text-4xl"> + {manaToUSD(totalRaised ?? 0)} raised + </div> + {userDonated > 0 && ( + <div className="text-primary text-xl"> + {manaToUSD(userDonated)} from you! + </div> + )} + {numSupporters > 0 && ( + <div className="text-gray-500">{numSupporters} supporters</div> + )} + <Linkify text={website} /> + </Col> + ) +} + +function DonationBox(props: { user?: User | null; charity: Charity }) { + const { user, charity } = props + const [amount, setAmount] = useState<number | undefined>() + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState<string | undefined>() + + const donateDisabled = isSubmitting || !amount || error + + const onSubmit: React.FormEventHandler = async (e) => { + if (!user || donateDisabled) return + + e.preventDefault() + setIsSubmitting(true) + setError(undefined) + await transact({ + amount, + fromId: user.id, + fromType: 'USER', + toId: charity.id, + toType: 'CHARITY', + token: 'M$', + category: 'CHARITY', + description: `${user.name} donated M$ ${amount} to ${charity.name}`, + }).catch((err) => console.log('Error', err)) + setIsSubmitting(false) + setAmount(undefined) + } + + return ( + <div className="rounded-lg bg-white py-6 px-8 shadow-lg"> + <div className="mb-6 text-2xl text-gray-700">Donate</div> + <form onSubmit={onSubmit}> + <label + className="mb-2 block text-sm text-gray-500" + htmlFor="donate-input" + > + Amount + </label> + <BuyAmountInput + inputClassName="w-full donate-input" + amount={amount} + onChange={setAmount} + error={error} + setError={setError} + /> + + <Col className="mt-3 w-full gap-3"> + <Row className="items-center justify-between text-sm"> + <span className="text-gray-500">Conversion</span> + <span> + {amount || 0} Mana + <span className="mx-2">→</span> + {manaToUSD(amount || 0)} + </span> + </Row> + {/* TODO: matching pool */} + </Col> + + <Spacer h={8} /> + + {user && ( + <button + type="submit" + className={clsx( + 'btn w-full', + donateDisabled ? 'btn-disabled' : 'btn-primary', + isSubmitting && 'loading' + )} + > + Donate + </button> + )} + </form> + </div> + ) +} diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx new file mode 100644 index 00000000..c2b5a270 --- /dev/null +++ b/web/pages/charity/index.tsx @@ -0,0 +1,64 @@ +import _ from 'lodash' +import { useState, useMemo } from 'react' +import { charities as charityList } from '../../../common/charity' +import { CharityCard } from '../../components/charity/charity-card' +import { Col } from '../../components/layout/col' +import { Page } from '../../components/page' +import { Title } from '../../components/title' + +// TODO: Fetch amount raised. +const charities = charityList.map((charity) => ({ + ...charity, + raised: 4001, +})) + +export default function Charity() { + const [query, setQuery] = useState('') + const debouncedQuery = _.debounce(setQuery, 50) + + const filterCharities = useMemo( + () => charities.filter((charity) => charity.name.includes(query)), + [query] + ) + + return ( + <Page> + <Col className="w-full items-center rounded px-4 py-6 sm:px-8 xl:w-[125%]"> + <Col className="max-w-xl gap-2"> + <Title className="!mt-0" text="Donate your M$ to charity!" /> + <div className="mb-6 text-gray-500"> + Throughout the month of May, every M$ 100 you contribute turns into + $1 USD to your chosen charity. We'll cover all processing fees! + </div> + + <input + type="text" + onChange={(e) => debouncedQuery(e.target.value)} + placeholder="Search charities" + className="input input-bordered mb-6 w-full" + /> + </Col> + <div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 lg:max-w-full lg:grid-cols-2 xl:grid-cols-3"> + {filterCharities.map((charity) => ( + <div key={charity.name}> + <CharityCard charity={charity} /> + </div> + ))} + </div> + {filterCharities.length === 0 && ( + <div className="text-center text-gray-500"> + No charities match your search :( + </div> + )} + + <div className="mt-10 italic text-gray-500"> + Note: Manifold is not affiliated with any of these charities, other + than being fans of their work. + <br /> + As Manifold is a for-profit entity, your contributions will not be tax + deductible. + </div> + </Col> + </Page> + ) +} diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 5a87e424..31c0c533 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -22,6 +22,7 @@ module.exports = { plugins: [ require('@tailwindcss/forms'), require('@tailwindcss/typography'), + require('@tailwindcss/line-clamp'), require('daisyui'), ], daisyui: { diff --git a/yarn.lock b/yarn.lock index 852ccf89..dfcb2a69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -906,6 +906,11 @@ dependencies: mini-svg-data-uri "^1.2.3" +"@tailwindcss/line-clamp@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@tailwindcss/line-clamp/-/line-clamp-0.3.1.tgz#4d8441b509b87ece84e94f28a4aa9998413ab849" + integrity sha512-pNr0T8LAc3TUx/gxCfQZRe9NB2dPEo/cedPHzUGIPxqDMhgjwNm6jYxww4W5l0zAsAddxr+XfZcqttGiFDgrGg== + "@tailwindcss/typography@^0.5.1": version "0.5.1" resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.1.tgz#486248a9426501f11a9b0295f7cfc0eb29659c46" From 78e8927de49afc1f9aac8ac11b2c19afb60c3f15 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Fri, 29 Apr 2022 19:38:31 -0400 Subject: [PATCH 11/68] Image preview: truncate to 100 chars, show avatar url (#111) * Truncate image preview to 120 chars * Try 100 chars instead * Pass along creatorAvatarUrl Hoping nothing breaks if the avatarUrl is empty * Thread through avatarUrl all the way * Fix typescript --- og-image/api/_lib/template.ts | 7 +++++-- web/components/SEO.tsx | 8 ++++++-- web/pages/[username]/[contractSlug].tsx | 15 +++++++++++---- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index 73105f6b..00d47394 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -85,7 +85,6 @@ export function getHtml(parsedReq: ParsedRequest) { const { theme, fontSize, - question, probability, metadata, @@ -93,6 +92,10 @@ export function getHtml(parsedReq: ParsedRequest) { creatorUsername, creatorAvatarUrl, } = parsedReq + const MAX_QUESTION_CHARS = 100 + const truncatedQuestion = question.length > MAX_QUESTION_CHARS + ? question.slice(0, MAX_QUESTION_CHARS) + '...' + : question const hideAvatar = creatorAvatarUrl ? '' : 'hidden' return `<!DOCTYPE html> <html> @@ -141,7 +144,7 @@ export function getHtml(parsedReq: ParsedRequest) { <div class="flex flex-row justify-between gap-12 pt-36"> <div class="text-indigo-700 text-6xl leading-tight"> - ${question} + ${truncatedQuestion} </div> <div class="flex flex-col text-primary"> <div class="text-8xl">${probability}</div> diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index 84ba850c..8987d671 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -6,8 +6,7 @@ export type OgCardProps = { metadata: string creatorName: string creatorUsername: string - // TODO: Store creator avatar url in each contract, then enable this - // creatorAvatarUrl: string + creatorAvatarUrl?: string } function buildCardUrl(props: OgCardProps) { @@ -15,6 +14,10 @@ function buildCardUrl(props: OgCardProps) { props.probability === undefined ? '' : `&probability=${encodeURIComponent(props.probability ?? '')}` + const creatorAvatarUrlParam = + props.creatorAvatarUrl === undefined + ? '' + : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` // URL encode each of the props, then add them as query params return ( @@ -23,6 +26,7 @@ function buildCardUrl(props: OgCardProps) { probabilityParam + `&metadata=${encodeURIComponent(props.metadata)}` + `&creatorName=${encodeURIComponent(props.creatorName)}` + + creatorAvatarUrlParam + `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` ) } diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 29786703..6fa84a7f 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -324,8 +324,14 @@ function ContractTopTrades(props: { } const getOpenGraphProps = (contract: Contract) => { - const { resolution, question, creatorName, creatorUsername, outcomeType } = - contract + const { + resolution, + question, + creatorName, + creatorUsername, + outcomeType, + creatorAvatarUrl, + } = contract const probPercent = outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined @@ -339,8 +345,9 @@ const getOpenGraphProps = (contract: Contract) => { question, probability: probPercent, metadata: contractTextDetails(contract), - creatorName: creatorName, - creatorUsername: creatorUsername, + creatorName, + creatorUsername, + creatorAvatarUrl, description, } } From 3bb4111445b4a941b4ec99c7ab6e974921b9a059 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 29 Apr 2022 23:55:32 -0400 Subject: [PATCH 12/68] Make charity cards extend same length in row. Tweak image padding --- web/components/charity/charity-card.tsx | 6 +++--- web/pages/charity/index.tsx | 5 +---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx index e50a9fe7..d86afde5 100644 --- a/web/components/charity/charity-card.tsx +++ b/web/components/charity/charity-card.tsx @@ -12,8 +12,8 @@ export function CharityCard(props: { charity: Charity }) { return ( <Link href={`/charity/${slug}`} passHref> - <div className="card card-compact transition:shadow cursor-pointer border-2 bg-white hover:shadow-md"> - <figure className="h-32"> + <div className="card card-compact transition:shadow flex-1 cursor-pointer border-2 bg-white hover:shadow-md"> + <figure className="h-32 px-4 pt-4"> {photo ? ( <img className="h-full w-full object-contain" src={photo} alt="" /> ) : ( @@ -23,7 +23,7 @@ export function CharityCard(props: { charity: Charity }) { <div className="card-body"> <h3 className="card-title line-clamp-3">{name}</h3> <div className="line-clamp-4 text-sm">{preview}</div> - <Row className="text-primary mt-4 items-end justify-center gap-1"> + <Row className="text-primary mt-4 flex-1 items-end justify-center gap-2"> <span className="text-3xl">${Math.floor((raised ?? 0) / 100)}</span> <span>raised</span> </Row> diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index c2b5a270..f481eaa3 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -6,7 +6,6 @@ import { Col } from '../../components/layout/col' import { Page } from '../../components/page' import { Title } from '../../components/title' -// TODO: Fetch amount raised. const charities = charityList.map((charity) => ({ ...charity, raised: 4001, @@ -40,9 +39,7 @@ export default function Charity() { </Col> <div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 lg:max-w-full lg:grid-cols-2 xl:grid-cols-3"> {filterCharities.map((charity) => ( - <div key={charity.name}> - <CharityCard charity={charity} /> - </div> + <CharityCard charity={charity} key={charity.name} /> ))} </div> {filterCharities.length === 0 && ( From 17b5345d824d697d7e7a19294b44bde06e3d5c3f Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 30 Apr 2022 04:44:11 -0700 Subject: [PATCH 13/68] Fix HTTP charity logo links (#113) --- common/charity.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/charity.ts b/common/charity.ts index a4cfd551..a45e51c3 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -78,7 +78,7 @@ export const charities: Charity[] = [ website: 'https://www.malariaconsortium.org/', ein: '98-0627052', photo: - 'http://www.malariaconsortium.org/website-2013/images_template/malaria_consortium_logo.png', + 'https://www.malariaconsortium.org/website-2013/images_template/malaria_consortium_logo.png', preview: 'We specialise in the prevention, control and treatment of malaria and other communicable diseases.', description: @@ -110,7 +110,7 @@ export const charities: Charity[] = [ website: 'https://wikimediafoundation.org/', ein: '20-0049703', photo: - 'http://2.bp.blogspot.com/-jVseU39DW0s/VjmXVMOEEEI/AAAAAAAACK8/dwUP6sLqy-Q/s1600/wikimedia.png', + 'https://2.bp.blogspot.com/-jVseU39DW0s/VjmXVMOEEEI/AAAAAAAACK8/dwUP6sLqy-Q/s1600/wikimedia.png', preview: 'We help everyone share in the sum of all knowledge.', description: 'We are the people who keep knowledge free. There is an amazing community of people around the world that makes great projects like Wikipedia. We help them do that work. We take care of the technical infrastructure, the legal challenges, and the growing pains.', @@ -120,7 +120,7 @@ export const charities: Charity[] = [ website: 'https://www.rainforesttrust.org/', ein: '13-3500609', photo: - 'http://ww1.prweb.com/prfiles/2019/05/29/16344590/Rrainforest%20Trust%20new%20logo%20tall-1%20copy.png', + 'https://ww1.prweb.com/prfiles/2019/05/29/16344590/Rrainforest%20Trust%20new%20logo%20tall-1%20copy.png', preview: 'Rainforest Trust saves endangered wildlife and protects our planet by creating rainforest reserves through partnerships, community engagement and donor support.', description: From a6de0ec69512a633effdf0c6e0f4fbb7728fcaf0 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 08:06:24 -0400 Subject: [PATCH 14/68] Add missing charity logos --- common/charity.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/charity.ts b/common/charity.ts index a45e51c3..4f437bab 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -160,6 +160,7 @@ export const charities: Charity[] = [ { name: 'UNICEF USA', website: 'https://www.unicefusa.org/', + photo: 'https://www.unicefusa.org/sites/default/files/UNICEFUSA_DIG_C.svg', ein: '13-1760110', preview: "UNICEF USA helps save and protect the world's most vulnerable children.", @@ -267,6 +268,8 @@ export const charities: Charity[] = [ { name: 'ALLFED', website: 'https://allfed.info/', + photo: + 'https://images1.the-dots.com/1860424/allfed-logo-1.png?p=projectImageFullJpg', ein: '27-6601178', preview: 'Feeding everyone no matter what.', description: From bd98e8810e65e767cf0728c9dfa94cd2dca91927 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 08:09:20 -0400 Subject: [PATCH 15/68] Disable "Read more..." for now --- web/pages/charity/[charitySlug].tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index e358f4e1..ff548dc5 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -76,7 +76,8 @@ function CharityPage(props: { charity: Charity }) { } function Blurb({ text }: { text: string }) { - const [open, setOpen] = useState(false) + // Default to open for now (aka don't actually hide any text yet.) + const [open, setOpen] = useState(true) // Calculate whether the full blurb is already shown const ref = useRef<HTMLDivElement>(null) From c51aa0b6b45e2ab274df6efadd09ed2f5217b38d Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 08:13:38 -0400 Subject: [PATCH 16/68] Simpify Donate panel --- web/pages/charity/[charitySlug].tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index ff548dc5..af3382e3 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -165,7 +165,7 @@ function DonationBox(props: { user?: User | null; charity: Charity }) { return ( <div className="rounded-lg bg-white py-6 px-8 shadow-lg"> - <div className="mb-6 text-2xl text-gray-700">Donate</div> + <Title text="Donate" className="!mt-0" /> <form onSubmit={onSubmit}> <label className="mb-2 block text-sm text-gray-500" @@ -183,12 +183,8 @@ function DonationBox(props: { user?: User | null; charity: Charity }) { <Col className="mt-3 w-full gap-3"> <Row className="items-center justify-between text-sm"> - <span className="text-gray-500">Conversion</span> - <span> - {amount || 0} Mana - <span className="mx-2">→</span> - {manaToUSD(amount || 0)} - </span> + <span className="text-gray-500">To {charity.name}</span> + <span>{manaToUSD(amount || 0)}</span> </Row> {/* TODO: matching pool */} </Col> From 774ba6fba6cf94e40baaa4eb808f39bac997b5e0 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 08:18:25 -0400 Subject: [PATCH 17/68] Show confetti on donate --- web/pages/charity/[charitySlug].tsx | 35 +++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index af3382e3..f4254293 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -15,6 +15,8 @@ import { charities, Charity } from '../../../common/charity' import { useRouter } from 'next/router' import Custom404 from '../404' import { useCharityTxns } from '../../hooks/use-charity-txns' +import { useWindowSize } from '../../hooks/use-window-size' +import Confetti from 'react-confetti' const manaToUSD = (mana: number) => (mana / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' }) @@ -46,8 +48,28 @@ function CharityPage(props: { charity: Charity }) { ) const numSupporters = _.uniqBy(txns, (txn) => txn.fromId).length + const { width, height } = useWindowSize() + const [showConfetti, setShowConfetti] = useState(false) + return ( - <Page rightSidebar={<DonationBox user={user} charity={charity} />}> + <Page + rightSidebar={ + <DonationBox + user={user} + charity={charity} + setShowConfetti={setShowConfetti} + /> + } + > + {showConfetti && ( + <Confetti + width={width ? width : 500} + height={height ? height : 500} + recycle={false} + numberOfPieces={300} + /> + )} + <Col className="mx-1 w-full items-center sm:px-0"> <Col className="max-w-2xl rounded bg-white px-8 py-6"> <Title className="!mt-0" text={name} /> @@ -135,8 +157,12 @@ function Details(props: { ) } -function DonationBox(props: { user?: User | null; charity: Charity }) { - const { user, charity } = props +function DonationBox(props: { + user?: User | null + charity: Charity + setShowConfetti: (show: boolean) => void +}) { + const { user, charity, setShowConfetti } = props const [amount, setAmount] = useState<number | undefined>() const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState<string | undefined>() @@ -161,6 +187,7 @@ function DonationBox(props: { user?: User | null; charity: Charity }) { }).catch((err) => console.log('Error', err)) setIsSubmitting(false) setAmount(undefined) + setShowConfetti(true) } return ( @@ -183,7 +210,7 @@ function DonationBox(props: { user?: User | null; charity: Charity }) { <Col className="mt-3 w-full gap-3"> <Row className="items-center justify-between text-sm"> - <span className="text-gray-500">To {charity.name}</span> + <span className="text-gray-500">{charity.name} receives</span> <span>{manaToUSD(amount || 0)}</span> </Row> {/* TODO: matching pool */} From 5c03f1581af1da988f70f4f60e943c5a0a124816 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 08:42:25 -0400 Subject: [PATCH 18/68] Fix lowercase search, tweak copy --- web/pages/charity/index.tsx | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index f481eaa3..1c546952 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -16,7 +16,10 @@ export default function Charity() { const debouncedQuery = _.debounce(setQuery, 50) const filterCharities = useMemo( - () => charities.filter((charity) => charity.name.includes(query)), + () => + charities.filter((charity) => + charity.name.toLowerCase().includes(query.toLowerCase()) + ), [query] ) @@ -24,10 +27,10 @@ export default function Charity() { <Page> <Col className="w-full items-center rounded px-4 py-6 sm:px-8 xl:w-[125%]"> <Col className="max-w-xl gap-2"> - <Title className="!mt-0" text="Donate your M$ to charity!" /> + <Title className="!mt-0" text="Manifold for Good" /> <div className="mb-6 text-gray-500"> - Throughout the month of May, every M$ 100 you contribute turns into - $1 USD to your chosen charity. We'll cover all processing fees! + Donate your winnings to charity! Through the month of May, every M$ + 100 you contribute turns into $1 USD sent to your chosen charity. </div> <input @@ -44,16 +47,22 @@ export default function Charity() { </div> {filterCharities.length === 0 && ( <div className="text-center text-gray-500"> - No charities match your search :( + 😢 We couldn't find that charity... </div> )} - <div className="mt-10 italic text-gray-500"> - Note: Manifold is not affiliated with any of these charities, other - than being fans of their work. + <div className="mt-10 text-gray-500"> + Don't see your favorite charity? Recommend that we add it by emailing + <span className="text-indigo-500"> give@manifold.markets</span>~ <br /> - As Manifold is a for-profit entity, your contributions will not be tax - deductible. + <br /> + <span className="italic"> + Note: Manifold is not affiliated with any of these charities (other + than being fans of their work!) + <br /> + As Manifold is a for-profit entity, your contributions will not be + tax deductible. + </span> </div> </Col> </Page> From cc300c84e15f170ebfb5d0e827c288739c153e3c Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 08:52:24 -0400 Subject: [PATCH 19/68] Adjust totalDeposits on charity donation --- functions/src/transact.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/functions/src/transact.ts b/functions/src/transact.ts index 04e58568..79b5ccb8 100644 --- a/functions/src/transact.ts +++ b/functions/src/transact.ts @@ -51,7 +51,10 @@ export const transact = functions return { status: 'error', message: 'User not found' } } const toUser = toSnap.data() as User - transaction.update(toDoc, { balance: toUser.balance + amount }) + transaction.update(toDoc, { + balance: toUser.balance + amount, + totalDeposits: toUser.totalDeposits + amount, + }) } const newTxnDoc = firestore.collection(`txns/`).doc() @@ -73,7 +76,10 @@ export const transact = functions }) transaction.create(newTxnDoc, txn) - transaction.update(fromDoc, { balance: fromUser.balance - amount }) + transaction.update(fromDoc, { + balance: fromUser.balance - amount, + totalDeposits: fromUser.totalDeposits - amount, + }) return { status: 'success', txn } }) From d2218b5b8bf11248813c51675fd47aac6244eb11 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 09:45:24 -0400 Subject: [PATCH 20/68] give@ isn't set up, use info@ --- web/pages/charity/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 1c546952..3cbb3819 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -53,7 +53,7 @@ export default function Charity() { <div className="mt-10 text-gray-500"> Don't see your favorite charity? Recommend that we add it by emailing - <span className="text-indigo-500"> give@manifold.markets</span>~ + <span className="text-indigo-500"> info@manifold.markets</span>~ <br /> <br /> <span className="italic"> From a4c722550a8bce11bdf10ba8d4f5ed593e843984 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 30 Apr 2022 10:07:32 -0400 Subject: [PATCH 21/68] Don't truncate comments for contract page --- web/components/feed/activity-items.ts | 2 +- web/components/feed/feed-items.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts index 7879b637..40dd2338 100644 --- a/web/components/feed/activity-items.ts +++ b/web/components/feed/activity-items.ts @@ -324,7 +324,7 @@ function getCommentsWithPositions( contract: contract, comment, betsBySameUser: bets.length === 0 ? [] : betsByUserId[comment.userId] ?? [], - truncate: true, + truncate: false, hideOutcome: false, smallAvatar: false, })) diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index 584907d9..aa501bcd 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -9,7 +9,6 @@ import { UserIcon, UsersIcon, XIcon, - SparklesIcon, } from '@heroicons/react/solid' import clsx from 'clsx' import Textarea from 'react-expanding-textarea' From 04c42e78353b6a08d5a0f0de8707d6c09c74ba1b Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 10:19:59 -0400 Subject: [PATCH 22/68] Tweak copy ("You with" => "You have") --- web/components/feed/feed-items.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index aa501bcd..c447f666 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -180,7 +180,7 @@ export function FeedComment(props: { />{' '} {!matchedBet && userPosition > 0 && ( <> - {'with ' + userPositionMoney + ' '} + {'had ' + userPositionMoney + ' '} <> {' of '} <OutcomeLabel @@ -269,7 +269,7 @@ export function CommentInput(props: { <div className="text-sm text-gray-500"> {user && userPosition > 0 && ( <> - {'You with ' + userPositionMoney + ' '} + {'You have ' + userPositionMoney + ' '} <> {' of '} <OutcomeLabel From ccd0e4273455fec0a9883467adff1629b5920a40 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 11:19:20 -0400 Subject: [PATCH 23/68] Revert "give@ isn't set up, use info@" This reverts commit d2218b5b8bf11248813c51675fd47aac6244eb11. --- web/pages/charity/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 3cbb3819..1c546952 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -53,7 +53,7 @@ export default function Charity() { <div className="mt-10 text-gray-500"> Don't see your favorite charity? Recommend that we add it by emailing - <span className="text-indigo-500"> info@manifold.markets</span>~ + <span className="text-indigo-500"> give@manifold.markets</span>~ <br /> <br /> <span className="italic"> From f6d440989920d7212b4b8c900a061b256b7764d8 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 30 Apr 2022 12:24:24 -0400 Subject: [PATCH 24/68] return liquidity pool after resolution according to true pool weight --- common/calculate-cpmm.ts | 32 ++++++++++++++++++++++++++++++++ common/payouts-fixed.ts | 21 +++++++++++---------- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index 88caf0d0..d36d2f21 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -2,6 +2,8 @@ import * as _ from 'lodash' import { Binary, CPMM, FullContract } from './contract' import { CREATOR_FEE, Fees, LIQUIDITY_FEE, noFees, PLATFORM_FEE } from './fees' +import { LiquidityProvision } from './liquidity-provision' +import { addObjects } from './util/object' export function getCpmmProbability( pool: { [outcome: string]: number }, @@ -258,6 +260,36 @@ export function addCpmmLiquidity( return { newPool, liquidity, newP } } +export function getCpmmLiquidityPoolWeights( + contract: FullContract<CPMM, Binary>, + liquidities: LiquidityProvision[] +) { + const { p } = contract + + const liquidityShares = liquidities.map((l) => { + const oldLiquidity = getCpmmLiquidity(l.pool, p) + + const newPool = addObjects(l.pool, { YES: l.amount, NO: l.amount }) + const newLiquidity = getCpmmLiquidity(newPool, p) + + const liquidity = newLiquidity - oldLiquidity + return liquidity + }) + + const shareSum = _.sum(liquidityShares) + + const weights = liquidityShares.map((s, i) => ({ + weight: s / shareSum, + providerId: liquidities[i].userId, + })) + + const userWeights = _.groupBy(weights, (w) => w.providerId) + const totalUserWeights = _.mapValues(userWeights, (userWeight) => + _.sumBy(userWeight, (w) => w.weight) + ) + return totalUserWeights +} + // export function removeCpmmLiquidity( // contract: FullContract<CPMM, Binary>, // liquidity: number diff --git a/common/payouts-fixed.ts b/common/payouts-fixed.ts index 3965c352..d06a8411 100644 --- a/common/payouts-fixed.ts +++ b/common/payouts-fixed.ts @@ -2,6 +2,7 @@ import * as _ from 'lodash' import { Bet } from './bet' import { getProbability } from './calculate' +import { getCpmmLiquidityPoolWeights } from './calculate-cpmm' import { Binary, CPMM, FixedPayouts, FullContract } from './contract' import { LiquidityProvision } from './liquidity-provision' @@ -60,14 +61,14 @@ export const getLiquidityPoolPayouts = ( outcome: string, liquidities: LiquidityProvision[] ) => { - const providedLiquidity = _.sumBy(liquidities, (lp) => lp.liquidity) - const { pool } = contract const finalPool = pool[outcome] - return liquidities.map((lp) => ({ - userId: lp.userId, - payout: (lp.liquidity / providedLiquidity) * finalPool, + const weights = getCpmmLiquidityPoolWeights(contract, liquidities) + + return Object.entries(weights).map(([providerId, weight]) => ({ + userId: providerId, + payout: weight * finalPool, })) } @@ -111,13 +112,13 @@ export const getLiquidityPoolProbPayouts = ( p: number, liquidities: LiquidityProvision[] ) => { - const providedLiquidity = _.sumBy(liquidities, (lp) => lp.liquidity) - const { pool } = contract const finalPool = p * pool.YES + (1 - p) * pool.NO - return liquidities.map((lp) => ({ - userId: lp.userId, - payout: (lp.liquidity / providedLiquidity) * finalPool, + const weights = getCpmmLiquidityPoolWeights(contract, liquidities) + + return Object.entries(weights).map(([providerId, weight]) => ({ + userId: providerId, + payout: weight * finalPool, })) } From f5e5af0b7ac455e65a165e58a814cf5ac719661a Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 13:05:43 -0400 Subject: [PATCH 25/68] Revert "bound initial probability to [0.1, 0.9]" This reverts commit da153ceea92a3d20914fff26f77f5c9c8204bed9. --- web/components/probability-selector.tsx | 22 +++++----------------- web/pages/create.tsx | 7 +------ 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/web/components/probability-selector.tsx b/web/components/probability-selector.tsx index 74dab3c6..2fc03787 100644 --- a/web/components/probability-selector.tsx +++ b/web/components/probability-selector.tsx @@ -4,11 +4,8 @@ export function ProbabilitySelector(props: { probabilityInt: number setProbabilityInt: (p: number) => void isSubmitting?: boolean - minProb?: number - maxProb?: number }) { - const { probabilityInt, setProbabilityInt, isSubmitting, minProb, maxProb } = - props + const { probabilityInt, setProbabilityInt, isSubmitting } = props return ( <Row className="items-center gap-2"> @@ -18,28 +15,19 @@ export function ProbabilitySelector(props: { value={probabilityInt} className="input input-bordered input-md text-lg" disabled={isSubmitting} - min={minProb ?? 1} - max={maxProb ?? 99} + min={1} + max={99} onChange={(e) => setProbabilityInt(parseInt(e.target.value.substring(0, 2))) } - onBlur={() => - setProbabilityInt( - maxProb && probabilityInt > maxProb - ? maxProb - : minProb && probabilityInt < minProb - ? minProb - : probabilityInt - ) - } /> <span>%</span> </label> <input type="range" className="range range-primary" - min={minProb ?? 1} - max={maxProb ?? 99} + min={1} + max={99} value={probabilityInt} onChange={(e) => setProbabilityInt(parseInt(e.target.value))} /> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index ab614167..9f4dae84 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -110,14 +110,11 @@ export function NewContract(props: { question: string; tag?: string }) { setIsSubmitting(true) - const boundedProb = - initialProb > 90 ? 90 : initialProb < 10 ? 10 : initialProb - const result: any = await createContract({ question, outcomeType, description, - initialProb: boundedProb, + initialProb, ante, closeTime, tags, @@ -179,8 +176,6 @@ export function NewContract(props: { question: string; tag?: string }) { <ProbabilitySelector probabilityInt={initialProb} setProbabilityInt={setInitialProb} - minProb={10} - maxProb={90} /> </div> )} From 1c7232f31e7cc5753bfdc916c012851481484278 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 15:26:07 -0400 Subject: [PATCH 26/68] Add 1Day Sooner & QURI as supported charities --- common/charity.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/common/charity.ts b/common/charity.ts index 4f437bab..88f5974d 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -3,13 +3,40 @@ export interface Charity { slug: string name: string website: string - ein: string + ein?: string photo?: string preview: string description: string + tags?: CharityTag[] } +type CharityTag = 'Featured' // | 'Health' | 'Poverty' | 'X-Risk' | 'Animal Welfare' | 'Policy' + export const charities: Charity[] = [ + { + name: '1Day Sooner', + website: 'https://www.1daysooner.org/', + preview: + 'Accelerating the development of each additional safe and effective vaccine by even a couple of days via COVID-19 human challenge trials could save thousands of lives.', + photo: + 'https://images.squarespace-cdn.com/content/v1/5f5f8496d1d7713486b6075a/666cbb5a-5335-4323-b9ea-b764edc826e1/OFFICIAL+1Day+Sooner+Logo.png', + description: `1Day Sooner is a non-profit that advocates on behalf of COVID-19 challenge trial volunteers. + After a vaccine candidate is created in a lab, it is developed through a combination of pre-clinical evaluation and three phases of clinical trials that test its safety and efficacy. In traditional Phase III trials, participants receive the vaccine candidate or a placebo/active comparator, and efficacy is judged by comparing the prevalence of infection in the vaccine group and the placebo/comparator group, to test the hypothesis that significantly fewer participants in the vaccine group get infected. In these traditional trials, after receiving the treatment, participants return to their homes and their normal daily lives so as to test the treatment under real world conditions. Since only a small proportion of these participants may encounter the disease, it may take a large number of participants and a good deal of time for these trials to reveal differences between the vaccine and placebo groups. + In a human challenge trial (HCT), willing participants would receive the vaccine candidate or placebo and, after some time for the vaccine to take effect, be deliberately exposed to live coronavirus. Since exposure to the virus is guaranteed in HCTs, it may be possible to judge a vaccine candidate’s efficacy more quickly and with far fewer participants than a standard Phase III trial. While HCT efficacy results do not traditionally provide sufficient basis for licensure on their own, they could allow us to (1) more quickly weed out disappointing vaccine candidates or (2) promote the development of promising candidates in conjunction with traditional Phase III studies. + In addition, by gathering detailed data on the process of infection and vaccine protection in a clinical setting, researchers could learn information that proves extremely useful for broader vaccine and therapeutic development efforts. Altogether, there are scenarios in which the speed of HCTs and the richness of the data they provide accelerate the development of an effective and broadly accessible COVID-19 vaccine, with thousands of lives spared (depending on the pandemic’s long-term trajectory).`, + tags: ['Featured'] as CharityTag[], + }, + { + name: 'QURI', + website: 'https://quantifieduncertainty.org/', + preview: + 'A new initiative to advance forecasting and epistemics with the aim of improving the long-term future of humanity.', + photo: + 'https://quantifieduncertainty.org/_next/image?url=https%3A%2F%2Fsuper-static-assets.s3.amazonaws.com%2F09bb1362-5e3f-4724-8ffd-f3235f67356f%2Fimages%2F6151ac3e-aed7-44c7-9827-399fe6e9222b.png&w=1920&q=80', + description: `QURI researches systematic practices to specify and estimate the most important parameters for the most important or scalable decisions. Research areas include forecasting, epistemics, evaluations, ontology, and estimation. We emphasize technological solutions that can heavily scale in the next 5 to 30 years. + We believe that humanity’s success in the next few hundred years will lie intensely on its ability to coordinate and make good decisions. If important governmental and philanthropic bodies become significantly more effective, this will make society far more resilient to many kinds of challenges ahead.`, + tags: ['Featured'] as CharityTag[], + }, { name: 'Faunalytics', website: 'https://faunalytics.org/', From 46bf09f18274557f327e45d6dea4e2221cfe9529 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 15:47:47 -0400 Subject: [PATCH 27/68] Feature 1Day Sooner & QURI --- common/charity.ts | 64 ++++++++++++------------- web/components/charity/charity-card.tsx | 27 +++++++++-- 2 files changed, 54 insertions(+), 37 deletions(-) diff --git a/common/charity.ts b/common/charity.ts index 88f5974d..9c9045b2 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -37,38 +37,6 @@ export const charities: Charity[] = [ We believe that humanity’s success in the next few hundred years will lie intensely on its ability to coordinate and make good decisions. If important governmental and philanthropic bodies become significantly more effective, this will make society far more resilient to many kinds of challenges ahead.`, tags: ['Featured'] as CharityTag[], }, - { - name: 'Faunalytics', - website: 'https://faunalytics.org/', - ein: '01-0686889', - photo: - 'https://animalcharityevaluators.org/wp-content/uploads/2016/08/logo-faunalytics2400x2400-200x200@2x.jpg', - preview: - 'Faunalytics conducts research and shares knowledge to help advocates help animals effectively.', - description: - "Faunalytics' mission is to empower animal advocates with access to research, analysis, strategies, and messages that maximize their effectiveness to reduce animal suffering.\n Animals need you, and you need data. We conduct essential research, maintain an online research library, and directly support advocates and organizations in their work to save lives. The range of data we offer helps our movement understand how people think about and respond to advocacy, providing advocates with the best strategies to inspire change for animals. ", - }, - { - name: 'The Humane League', - website: 'https://thehumaneleague.org/', - ein: '04-3817491', - photo: - 'https://animalcharityevaluators.org/wp-content/uploads/2019/03/thl-mended-heart-logo@2x-200x200@2x.jpg', - preview: - 'We exist to end the abuse of animals raised for food by influencing the policies of the world’s biggest companies, demanding legislation, and empowering others to take action and leave animals off their plates', - description: - 'The Humane League (THL) currently operates in the U.S., Mexico, the U.K., and Japan, where they work to improve animal welfare standards through grassroots campaigns, movement building, veg*n advocacy, research, and advocacy training, as well as through corporate, media, and community outreach. They work to build the animal advocacy movement internationally through the Open Wing Alliance (OWA), a coalition founded by THL whose mission is to end the use of battery cages globally.', - }, - { - name: 'Wild Animal Initiative', - website: 'https://www.wildanimalinitiative.org/', - ein: '82-2281466', - photo: - 'https://animalcharityevaluators.org/wp-content/uploads/2020/11/WAI-logo_square-gray-on-teal-1-630x630.png', - preview: 'We want to make life better for wild animals.', - description: - 'Wild Animal Initiative (WAI) currently operates in the U.S., where they work to strengthen the animal advocacy movement through creating an academic field dedicated to wild animal welfare. They compile literature reviews, write theoretical and opinion articles, and publish research results on their website and/or in peer-reviewed journals. WAI focuses on identifying and sharing possible research avenues and connecting with more established fields. They also work with researchers from various academic and non-academic institutions to identify potential collaborators, and they recently launched a grant assistance program.', - }, { name: 'Give Directly', website: 'https://www.givedirectly.org/', @@ -111,6 +79,38 @@ export const charities: Charity[] = [ description: 'We are dedicated to ensuring our work is supported by strong evidence and remains grounded in the lessons we learn through implementation. We explore beyond current practice, to try out innovative ways – through research, implementation and policy development – to achieve effective and sustainable disease management and control.', }, + { + name: 'Faunalytics', + website: 'https://faunalytics.org/', + ein: '01-0686889', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2016/08/logo-faunalytics2400x2400-200x200@2x.jpg', + preview: + 'Faunalytics conducts research and shares knowledge to help advocates help animals effectively.', + description: + "Faunalytics' mission is to empower animal advocates with access to research, analysis, strategies, and messages that maximize their effectiveness to reduce animal suffering.\n Animals need you, and you need data. We conduct essential research, maintain an online research library, and directly support advocates and organizations in their work to save lives. The range of data we offer helps our movement understand how people think about and respond to advocacy, providing advocates with the best strategies to inspire change for animals. ", + }, + { + name: 'The Humane League', + website: 'https://thehumaneleague.org/', + ein: '04-3817491', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2019/03/thl-mended-heart-logo@2x-200x200@2x.jpg', + preview: + 'We exist to end the abuse of animals raised for food by influencing the policies of the world’s biggest companies, demanding legislation, and empowering others to take action and leave animals off their plates', + description: + 'The Humane League (THL) currently operates in the U.S., Mexico, the U.K., and Japan, where they work to improve animal welfare standards through grassroots campaigns, movement building, veg*n advocacy, research, and advocacy training, as well as through corporate, media, and community outreach. They work to build the animal advocacy movement internationally through the Open Wing Alliance (OWA), a coalition founded by THL whose mission is to end the use of battery cages globally.', + }, + { + name: 'Wild Animal Initiative', + website: 'https://www.wildanimalinitiative.org/', + ein: '82-2281466', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2020/11/WAI-logo_square-gray-on-teal-1-630x630.png', + preview: 'We want to make life better for wild animals.', + description: + 'Wild Animal Initiative (WAI) currently operates in the U.S., where they work to strengthen the animal advocacy movement through creating an academic field dedicated to wild animal welfare. They compile literature reviews, write theoretical and opinion articles, and publish research results on their website and/or in peer-reviewed journals. WAI focuses on identifying and sharing possible research avenues and connecting with more established fields. They also work with researchers from various academic and non-academic institutions to identify potential collaborators, and they recently launched a grant assistance program.', + }, { name: 'New Incentives', website: 'https://www.newincentives.org/', diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx index d86afde5..d1763ca8 100644 --- a/web/components/charity/charity-card.tsx +++ b/web/components/charity/charity-card.tsx @@ -1,3 +1,4 @@ +import { StarIcon } from '@heroicons/react/solid' import _ from 'lodash' import Link from 'next/link' import { Charity } from '../../../common/charity' @@ -5,7 +6,7 @@ import { useCharityTxns } from '../../hooks/use-charity-txns' import { Row } from '../layout/row' export function CharityCard(props: { charity: Charity }) { - const { name, slug, photo, preview, id } = props.charity + const { name, slug, photo, preview, id, tags } = props.charity const txns = useCharityTxns(id) const raised = _.sumBy(txns, (txn) => txn.amount) @@ -13,6 +14,10 @@ export function CharityCard(props: { charity: Charity }) { return ( <Link href={`/charity/${slug}`} passHref> <div className="card card-compact transition:shadow flex-1 cursor-pointer border-2 bg-white hover:shadow-md"> + <Row className="mt-6"> + {tags?.includes('Featured') && <FeaturedBadge />} + </Row> + <figure className="h-32 px-4 pt-4"> {photo ? ( <img className="h-full w-full object-contain" src={photo} alt="" /> @@ -23,12 +28,24 @@ export function CharityCard(props: { charity: Charity }) { <div className="card-body"> <h3 className="card-title line-clamp-3">{name}</h3> <div className="line-clamp-4 text-sm">{preview}</div> - <Row className="text-primary mt-4 flex-1 items-end justify-center gap-2"> - <span className="text-3xl">${Math.floor((raised ?? 0) / 100)}</span> - <span>raised</span> - </Row> + {raised > 0 && ( + <Row className="text-primary mt-4 flex-1 items-end justify-center gap-2"> + <span className="text-3xl"> + ${Math.floor((raised ?? 0) / 100)} + </span> + <span>raised</span> + </Row> + )} </div> </div> </Link> ) } + +function FeaturedBadge() { + return ( + <span className="inline-flex items-center gap-1 bg-yellow-100 px-3 py-0.5 text-sm font-medium text-yellow-800"> + <StarIcon className="h-4 w-4" aria-hidden="true" /> Featured + </span> + ) +} From a3311bd5aa056378b4d043016186767ba67a7e5a Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 30 Apr 2022 16:27:19 -0400 Subject: [PATCH 28/68] embed total donations market on charity page --- web/pages/charity/index.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 1c546952..57008678 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -51,6 +51,15 @@ export default function Charity() { </div> )} + <iframe + width="560" + height="405" + src="https://manifold.markets/embed/ManifoldMarkets/total-donations-for-manifold-for-go" + title="Total donations for Manifold for Good this May (in USD)" + frameBorder="0" + className="m-10" + ></iframe> + <div className="mt-10 text-gray-500"> Don't see your favorite charity? Recommend that we add it by emailing <span className="text-indigo-500"> give@manifold.markets</span>~ From 731e5d5b7c981d91962598a5bb8e409b2a758281 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 30 Apr 2022 13:30:49 -0700 Subject: [PATCH 29/68] Apply permissive CORS headers to API (#115) * Take cors package as dependency * Apply permissive CORS headers to all API routes --- web/lib/api/cors.ts | 20 ++++++++++++++++++++ web/package.json | 1 + web/pages/api/v0/market/[id].ts | 2 ++ web/pages/api/v0/markets.ts | 2 ++ web/pages/api/v0/slug/[slug].ts | 2 ++ 5 files changed, 27 insertions(+) create mode 100644 web/lib/api/cors.ts diff --git a/web/lib/api/cors.ts b/web/lib/api/cors.ts new file mode 100644 index 00000000..976a0ffc --- /dev/null +++ b/web/lib/api/cors.ts @@ -0,0 +1,20 @@ +import Cors from 'cors' +import { NextApiRequest, NextApiResponse } from 'next' + +export function applyCorsHeaders( + req: NextApiRequest, + res: NextApiResponse, + params: object +) { + // This cors module is made as express.js middleware, so it's easier to promisify it for ourselves. + return new Promise((resolve, reject) => { + Cors(params)(req, res, (result) => { + if (result instanceof Error) { + return reject(result) + } + return resolve(result) + }) + }) +} + +export const CORS_UNRESTRICTED = {} diff --git a/web/package.json b/web/package.json index 0e770f96..d29a01b5 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,7 @@ "@nivo/core": "0.74.0", "@nivo/line": "0.74.0", "clsx": "1.1.1", + "cors": "^2.8.5", "daisyui": "1.16.4", "dayjs": "1.10.7", "firebase": "9.6.0", diff --git a/web/pages/api/v0/market/[id].ts b/web/pages/api/v0/market/[id].ts index c6dee26a..2faa9c57 100644 --- a/web/pages/api/v0/market/[id].ts +++ b/web/pages/api/v0/market/[id].ts @@ -3,11 +3,13 @@ import { Bet, listAllBets } from '../../../../lib/firebase/bets' import { listAllComments } from '../../../../lib/firebase/comments' import { getContractFromId } from '../../../../lib/firebase/contracts' import { FullMarket, ApiError, toLiteMarket } from '../_types' +import { applyCorsHeaders, CORS_UNRESTRICTED } from '../../../../lib/api/cors' export default async function handler( req: NextApiRequest, res: NextApiResponse<FullMarket | ApiError> ) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) const { id } = req.query const contractId = id as string diff --git a/web/pages/api/v0/markets.ts b/web/pages/api/v0/markets.ts index 5bda56f6..a27fd27a 100644 --- a/web/pages/api/v0/markets.ts +++ b/web/pages/api/v0/markets.ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { listAllContracts } from '../../../lib/firebase/contracts' import { toLiteMarket } from './_types' +import { applyCorsHeaders, CORS_UNRESTRICTED } from '../../../lib/api/cors' type Data = any[] @@ -9,6 +10,7 @@ export default async function handler( req: NextApiRequest, res: NextApiResponse<Data> ) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) const contracts = await listAllContracts() // Serve from Vercel cache, then update. see https://vercel.com/docs/concepts/functions/edge-caching res.setHeader('Cache-Control', 's-maxage=1, stale-while-revalidate') diff --git a/web/pages/api/v0/slug/[slug].ts b/web/pages/api/v0/slug/[slug].ts index 07c39a91..c4bba82a 100644 --- a/web/pages/api/v0/slug/[slug].ts +++ b/web/pages/api/v0/slug/[slug].ts @@ -3,11 +3,13 @@ import { Bet, listAllBets } from '../../../../lib/firebase/bets' import { listAllComments } from '../../../../lib/firebase/comments' import { getContractFromSlug } from '../../../../lib/firebase/contracts' import { FullMarket, ApiError, toLiteMarket } from '../_types' +import { applyCorsHeaders, CORS_UNRESTRICTED } from '../../../../lib/api/cors' export default async function handler( req: NextApiRequest, res: NextApiResponse<FullMarket | ApiError> ) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) const { slug } = req.query const contract = await getContractFromSlug(slug as string) From 53a584f37daf160b1a6a7797425be6fd0be4cd3d Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 17:35:06 -0400 Subject: [PATCH 30/68] Add in LTFF, ARC, RC --- common/charity.ts | 64 ++++++++++++++++++++++++++--- web/pages/charity/[charitySlug].tsx | 5 ++- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/common/charity.ts b/common/charity.ts index 9c9045b2..62698445 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -17,13 +17,16 @@ export const charities: Charity[] = [ name: '1Day Sooner', website: 'https://www.1daysooner.org/', preview: - 'Accelerating the development of each additional safe and effective vaccine by even a couple of days via COVID-19 human challenge trials could save thousands of lives.', + 'Accelerating the development of each vaccine by even a couple of days via COVID-19 human challenge trials could save thousands of lives.', photo: 'https://images.squarespace-cdn.com/content/v1/5f5f8496d1d7713486b6075a/666cbb5a-5335-4323-b9ea-b764edc826e1/OFFICIAL+1Day+Sooner+Logo.png', description: `1Day Sooner is a non-profit that advocates on behalf of COVID-19 challenge trial volunteers. - After a vaccine candidate is created in a lab, it is developed through a combination of pre-clinical evaluation and three phases of clinical trials that test its safety and efficacy. In traditional Phase III trials, participants receive the vaccine candidate or a placebo/active comparator, and efficacy is judged by comparing the prevalence of infection in the vaccine group and the placebo/comparator group, to test the hypothesis that significantly fewer participants in the vaccine group get infected. In these traditional trials, after receiving the treatment, participants return to their homes and their normal daily lives so as to test the treatment under real world conditions. Since only a small proportion of these participants may encounter the disease, it may take a large number of participants and a good deal of time for these trials to reveal differences between the vaccine and placebo groups. - In a human challenge trial (HCT), willing participants would receive the vaccine candidate or placebo and, after some time for the vaccine to take effect, be deliberately exposed to live coronavirus. Since exposure to the virus is guaranteed in HCTs, it may be possible to judge a vaccine candidate’s efficacy more quickly and with far fewer participants than a standard Phase III trial. While HCT efficacy results do not traditionally provide sufficient basis for licensure on their own, they could allow us to (1) more quickly weed out disappointing vaccine candidates or (2) promote the development of promising candidates in conjunction with traditional Phase III studies. - In addition, by gathering detailed data on the process of infection and vaccine protection in a clinical setting, researchers could learn information that proves extremely useful for broader vaccine and therapeutic development efforts. Altogether, there are scenarios in which the speed of HCTs and the richness of the data they provide accelerate the development of an effective and broadly accessible COVID-19 vaccine, with thousands of lives spared (depending on the pandemic’s long-term trajectory).`, + + After a vaccine candidate is created in a lab, it is developed through a combination of pre-clinical evaluation and three phases of clinical trials that test its safety and efficacy. In traditional Phase III trials, participants receive the vaccine candidate or a placebo/active comparator, and efficacy is judged by comparing the prevalence of infection in the vaccine group and the placebo/comparator group, to test the hypothesis that significantly fewer participants in the vaccine group get infected. In these traditional trials, after receiving the treatment, participants return to their homes and their normal daily lives so as to test the treatment under real world conditions. Since only a small proportion of these participants may encounter the disease, it may take a large number of participants and a good deal of time for these trials to reveal differences between the vaccine and placebo groups. + + In a human challenge trial (HCT), willing participants would receive the vaccine candidate or placebo and, after some time for the vaccine to take effect, be deliberately exposed to live coronavirus. Since exposure to the virus is guaranteed in HCTs, it may be possible to judge a vaccine candidate’s efficacy more quickly and with far fewer participants than a standard Phase III trial. While HCT efficacy results do not traditionally provide sufficient basis for licensure on their own, they could allow us to (1) more quickly weed out disappointing vaccine candidates or (2) promote the development of promising candidates in conjunction with traditional Phase III studies. + + In addition, by gathering detailed data on the process of infection and vaccine protection in a clinical setting, researchers could learn information that proves extremely useful for broader vaccine and therapeutic development efforts. Altogether, there are scenarios in which the speed of HCTs and the richness of the data they provide accelerate the development of an effective and broadly accessible COVID-19 vaccine, with thousands of lives spared (depending on the pandemic’s long-term trajectory).`, tags: ['Featured'] as CharityTag[], }, { @@ -33,10 +36,45 @@ export const charities: Charity[] = [ 'A new initiative to advance forecasting and epistemics with the aim of improving the long-term future of humanity.', photo: 'https://quantifieduncertainty.org/_next/image?url=https%3A%2F%2Fsuper-static-assets.s3.amazonaws.com%2F09bb1362-5e3f-4724-8ffd-f3235f67356f%2Fimages%2F6151ac3e-aed7-44c7-9827-399fe6e9222b.png&w=1920&q=80', - description: `QURI researches systematic practices to specify and estimate the most important parameters for the most important or scalable decisions. Research areas include forecasting, epistemics, evaluations, ontology, and estimation. We emphasize technological solutions that can heavily scale in the next 5 to 30 years. + description: `QURI researches systematic practices to specify and estimate the most important parameters for the most important or scalable decisions. Research areas include forecasting, epistemics, evaluations, ontology, and estimation. + + We emphasize technological solutions that can heavily scale in the next 5 to 30 years. + We believe that humanity’s success in the next few hundred years will lie intensely on its ability to coordinate and make good decisions. If important governmental and philanthropic bodies become significantly more effective, this will make society far more resilient to many kinds of challenges ahead.`, tags: ['Featured'] as CharityTag[], }, + { + name: 'Long-Term Future Fund', + website: 'https://funds.effectivealtruism.org/funds/far-future', + photo: 'https://app.effectivealtruism.org/logo-funds.svg', + preview: + 'Positively influence the long-term trajectory of civilization by making grants that address global catastrophic risks.', + description: `The Fund has a broad remit to make grants that promote, implement and advocate for longtermist ideas. Many of our grants aim to address potential risks from advanced artificial intelligence and to build infrastructure and advocate for longtermist projects. However, we welcome applications related to long-term institutional reform or other global catastrophic risks (e.g., pandemics or nuclear conflict). + + We intend to support: + - Projects that directly contribute to reducing existential risks through technical research, policy analysis, advocacy, and/or demonstration projects + - Training for researchers or practitioners who work to mitigate existential risks, or help with relevant recruitment efforts, or infrastructure for people working on longtermist projects + - Promoting long-term thinking`, + tags: ['Featured'] as CharityTag[], + }, + { + name: 'ARC', + website: 'https://alignment.org/', + photo: 'https://i.imgur.com/Hwg8OMP.png', + preview: 'Align future machine learning systems with human interests.', + description: `ARC is a non-profit research organization whose mission is to align future machine learning systems with human interests. Its current work focuses on developing an alignment strategy that could be adopted in industry today while scaling gracefully to future ML systems. Right now Paul Christiano and Mark Xu are researchers and Kyle Scott handles operations. + +What is “alignment”? ML systems can exhibit goal-directed behavior, but it is difficult to understand or control what they are “trying” to do. Powerful models could cause harm if they were trying to manipulate and deceive humans. The goal of intent alignment is to instead train these models to be helpful and honest. + +Motivation: We believe that modern ML techniques would lead to severe misalignment if scaled up to large enough computers and datasets. Practitioners may be able to adapt before these failures have catastrophic consequences, but we could reduce the risk by adopting scalable methods further in advance. + +What we’re working on: The best way to understand our research priorities and methodology is probably to read our report on Eliciting Latent Knowledge. At a high level, we’re trying to figure out how to train ML systems to answer questions by straightforwardly “translating” their beliefs into natural language rather than by reasoning about what a human wants to hear. + +Methodology: We’re unsatisfied with an algorithm if we can see any plausible story about how it eventually breaks down, which means that we can rule out most algorithms on paper without ever implementing them. The cost of this approach is that it may completely miss strategies that exploit important structure in realistic ML models; the benefit is that you can consider lots of ideas quickly. (More) + +Future plans: We expect to focus on similar theoretical problems in alignment until we either become more pessimistic about tractability or ARC grows enough to branch out into other areas. Over the long term we are likely to work on a combination of theoretical and empirical alignment research, collaborations with industry labs, alignment forecasting, and ML deployment policy.`, + tags: ['Featured'] as CharityTag[], + }, { name: 'Give Directly', website: 'https://www.givedirectly.org/', @@ -54,7 +92,7 @@ export const charities: Charity[] = [ photo: 'https://www.ntd-ngonetwork.org/sites/nnn/files/content/organisation/logos/2020-01-28/v2_HKLogo_Primary_RGB.jpg', preview: - 'At Helen Keller Intl, we envision a world where no one is deprived of the opportunity to live a healthy life – and reach their true potential.', + 'We envision a world where no one is deprived of the opportunity to live a healthy life – and reach their true potential.', description: 'Right now, 36 million people worldwide — most of them in developing countries — are blind.\n 90 percent of them didn’t have to lose their sight. Helen Keller International is dedicated to combating the causes and consequences of vision loss and making clear vision a reality for those most vulnerable to disease and who lack access to quality eye care.\n Last year alone, we helped provide many tens of millions of people with treatment to prevent diseases of poverty including blinding trachoma and river blindness.\n Surgeons trained by our staff also performed tens of thousands of cataract surgeries in the developing world.  And in the United States, we screened the vision of nearly 66,000 students living in some of our country’s poorest neighborhoods and provided free eyeglasses to just over 16,000 of them. ', }, @@ -68,6 +106,20 @@ export const charities: Charity[] = [ description: 'AMF (againstmalaria.com) provides funding for long-lasting insecticide-treated net (LLIN) distributions (for protection against malaria) in developing countries. There is strong evidence that distributing LLINs reduces child mortality and malaria cases. AMF conducts post-distribution surveys of completed distributions to determine whether LLINs have reached their intended destinations and how long they remain in good condition.', }, + { + name: 'Rethink Charity', + website: 'https://rethink.charity/', + photo: + 'https://process.filestackapi.com/resize=width:600,height:315,fit:max/quality=value:90/jvYvq1JFQkOqo3J8hVcJ', + preview: + 'Providing vital support to high-impact charities and charitable projects.', + description: `At Rethink Charity, we’re excited about improving the world by providing vital support to high-impact charities and charitable projects. We equip them with tools to boost their impact, through our projects that empower their donors with tax-efficient giving options and strategically coordinated matching opportunities. + What we do: + + - Rethink Charity Forward is a cause-neutral donation routing fund for high-impact charities around the world. Canadians have used RC Forward to donate $10 million to high-impact charities since the project was launched in late 2017. + + - EA Giving Tuesday supports both donors and highly effective nonprofits participating in Facebook’s annual Giving Tuesday match. In addition to setting up systems and processes, the team provides analysis-based recommendations, detailed instructions, and responsive support. The team’s goal is to make it as easy as possible for donors to direct matching dollars to highly effective nonprofits.`, + }, { name: 'Malaria Consortium', website: 'https://www.malariaconsortium.org/', diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index f4254293..b01f19e1 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -113,7 +113,10 @@ function Blurb({ text }: { text: string }) { return ( <> <div - className={clsx(' text-gray-500', !open && 'line-clamp-5')} + className={clsx( + 'whitespace-pre-line text-gray-500', + !open && 'line-clamp-5' + )} ref={ref} > {text} From bbc8915f790ad38a9c7707382cbe32a73efe2916 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 17:42:08 -0400 Subject: [PATCH 31/68] Clean up prediction market on charities --- web/pages/charity/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 57008678..5a9c77e9 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -52,12 +52,11 @@ export default function Charity() { )} <iframe - width="560" height="405" src="https://manifold.markets/embed/ManifoldMarkets/total-donations-for-manifold-for-go" title="Total donations for Manifold for Good this May (in USD)" frameBorder="0" - className="m-10" + className="m-10 w-full rounded-xl bg-white p-10" ></iframe> <div className="mt-10 text-gray-500"> From ade8eb7aae000c34826677af8c271464cf924ae5 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 18:00:51 -0400 Subject: [PATCH 32/68] Add CSPI --- common/charity.ts | 53 ++++++++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/common/charity.ts b/common/charity.ts index 62698445..4ae0e5c5 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -30,7 +30,7 @@ export const charities: Charity[] = [ tags: ['Featured'] as CharityTag[], }, { - name: 'QURI', + name: 'The Quantified Uncertainty Research Institute', website: 'https://quantifieduncertainty.org/', preview: 'A new initiative to advance forecasting and epistemics with the aim of improving the long-term future of humanity.', @@ -57,24 +57,6 @@ export const charities: Charity[] = [ - Promoting long-term thinking`, tags: ['Featured'] as CharityTag[], }, - { - name: 'ARC', - website: 'https://alignment.org/', - photo: 'https://i.imgur.com/Hwg8OMP.png', - preview: 'Align future machine learning systems with human interests.', - description: `ARC is a non-profit research organization whose mission is to align future machine learning systems with human interests. Its current work focuses on developing an alignment strategy that could be adopted in industry today while scaling gracefully to future ML systems. Right now Paul Christiano and Mark Xu are researchers and Kyle Scott handles operations. - -What is “alignment”? ML systems can exhibit goal-directed behavior, but it is difficult to understand or control what they are “trying” to do. Powerful models could cause harm if they were trying to manipulate and deceive humans. The goal of intent alignment is to instead train these models to be helpful and honest. - -Motivation: We believe that modern ML techniques would lead to severe misalignment if scaled up to large enough computers and datasets. Practitioners may be able to adapt before these failures have catastrophic consequences, but we could reduce the risk by adopting scalable methods further in advance. - -What we’re working on: The best way to understand our research priorities and methodology is probably to read our report on Eliciting Latent Knowledge. At a high level, we’re trying to figure out how to train ML systems to answer questions by straightforwardly “translating” their beliefs into natural language rather than by reasoning about what a human wants to hear. - -Methodology: We’re unsatisfied with an algorithm if we can see any plausible story about how it eventually breaks down, which means that we can rule out most algorithms on paper without ever implementing them. The cost of this approach is that it may completely miss strategies that exploit important structure in realistic ML models; the benefit is that you can consider lots of ideas quickly. (More) - -Future plans: We expect to focus on similar theoretical problems in alignment until we either become more pessimistic about tractability or ARC grows enough to branch out into other areas. Over the long term we are likely to work on a combination of theoretical and empirical alignment research, collaborations with industry labs, alignment forecasting, and ML deployment policy.`, - tags: ['Featured'] as CharityTag[], - }, { name: 'Give Directly', website: 'https://www.givedirectly.org/', @@ -131,6 +113,39 @@ Future plans: We expect to focus on similar theoretical problems in alignment un description: 'We are dedicated to ensuring our work is supported by strong evidence and remains grounded in the lessons we learn through implementation. We explore beyond current practice, to try out innovative ways – through research, implementation and policy development – to achieve effective and sustainable disease management and control.', }, + { + name: 'ARC', + website: 'https://alignment.org/', + photo: 'https://i.imgur.com/Hwg8OMP.png', + preview: 'Align future machine learning systems with human interests.', + description: `ARC is a non-profit research organization whose mission is to align future machine learning systems with human interests. Its current work focuses on developing an alignment strategy that could be adopted in industry today while scaling gracefully to future ML systems. Right now Paul Christiano and Mark Xu are researchers and Kyle Scott handles operations. + +What is “alignment”? ML systems can exhibit goal-directed behavior, but it is difficult to understand or control what they are “trying” to do. Powerful models could cause harm if they were trying to manipulate and deceive humans. The goal of intent alignment is to instead train these models to be helpful and honest. + +Motivation: We believe that modern ML techniques would lead to severe misalignment if scaled up to large enough computers and datasets. Practitioners may be able to adapt before these failures have catastrophic consequences, but we could reduce the risk by adopting scalable methods further in advance. + +What we’re working on: The best way to understand our research priorities and methodology is probably to read our report on Eliciting Latent Knowledge. At a high level, we’re trying to figure out how to train ML systems to answer questions by straightforwardly “translating” their beliefs into natural language rather than by reasoning about what a human wants to hear. + +Methodology: We’re unsatisfied with an algorithm if we can see any plausible story about how it eventually breaks down, which means that we can rule out most algorithms on paper without ever implementing them. The cost of this approach is that it may completely miss strategies that exploit important structure in realistic ML models; the benefit is that you can consider lots of ideas quickly. (More) + +Future plans: We expect to focus on similar theoretical problems in alignment until we either become more pessimistic about tractability or ARC grows enough to branch out into other areas. Over the long term we are likely to work on a combination of theoretical and empirical alignment research, collaborations with industry labs, alignment forecasting, and ML deployment policy.`, + }, + { + name: 'The Center for the Study of Partisanship and Ideology', + website: 'https://cspicenter.org/', + photo: 'https://cspicenter.org/wp-content/uploads/2020/02/CSPI.png', + preview: + 'Support and fund research on how ideology and government policy contribute to scientific, technological, and social progress.', + description: `Over the last few decades, scientific and technological progress have stagnated. Scientists conduct more research than ever before, but groundbreaking innovation is scarce. At the same time, identity politics and political polarization have reached new extremes, and social trends such as family stability and crime are worse than in previous decades and in some cases moving in the wrong direction. What explains these trends, and how can we reverse them? + + Much of the blame lies with the institutions we rely on for administration, innovation, and leadership. Instead of forward-looking governments, we have short-sighted politicians and bloated bureaucracies. Instead of real experts with proven track records, we have so-called ‘experts’ who appeal to the authority of their credentials. Instead of political leaders willing to face facts and make tough tradeoffs, we have politicians who appeal to ignorance and defer responsibility. + + To fix our institutions, we need to rethink them from the ground up. That is why CSPI supports and funds research into the administrative systems, organizational structures, and political ideologies of modern governance. Only by understanding what makes these systems so often dysfunctional can we change them for the better. + + CSPI believes that governments should be accountable to the populace as a whole, not special interest groups. We think experts should have greater say in public policy, but that there should be different standards for what qualifies as “expertise.” We want to end scientific and technological stagnation and usher in a new era of growth and innovation. + + We are interested in funding and supporting research that can speak to these issues in the social sciences through grants and fellowships. CSPI particularly seek outs work that is unlikely to receive support elsewhere. See our home page for more about the kinds of research we are particularly interested in funding.`, + }, { name: 'Faunalytics', website: 'https://faunalytics.org/', From 80d594bd5f8e959cf689c3fd4fdce1ae00bf8abc Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sun, 1 May 2022 08:30:55 -0400 Subject: [PATCH 33/68] Rename QURI back --- common/charity.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/charity.ts b/common/charity.ts index 4ae0e5c5..9754b5ea 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -12,6 +12,7 @@ export interface Charity { type CharityTag = 'Featured' // | 'Health' | 'Poverty' | 'X-Risk' | 'Animal Welfare' | 'Policy' +// Warning: 'name' is currently used as the slug and the txn toId for the charity. export const charities: Charity[] = [ { name: '1Day Sooner', @@ -30,10 +31,10 @@ export const charities: Charity[] = [ tags: ['Featured'] as CharityTag[], }, { - name: 'The Quantified Uncertainty Research Institute', + name: 'QURI', website: 'https://quantifieduncertainty.org/', preview: - 'A new initiative to advance forecasting and epistemics with the aim of improving the long-term future of humanity.', + 'The Quantified Uncertainty Research Institute advances forecasting and epistemics to improve the long-term future of humanity.', photo: 'https://quantifieduncertainty.org/_next/image?url=https%3A%2F%2Fsuper-static-assets.s3.amazonaws.com%2F09bb1362-5e3f-4724-8ffd-f3235f67356f%2Fimages%2F6151ac3e-aed7-44c7-9827-399fe6e9222b.png&w=1920&q=80', description: `QURI researches systematic practices to specify and estimate the most important parameters for the most important or scalable decisions. Research areas include forecasting, epistemics, evaluations, ontology, and estimation. From 06b7e49e98184b78ad2cc65dc4fd7571988d2e35 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 1 May 2022 11:36:54 -0500 Subject: [PATCH 34/68] [In progress] Server-side feed computation (#106) * Store view counts & last viewed time * Schedule updating user recommendations. Compute using tf-idf. * Update contract's lastBetTime and lastCommentTime on new bets and comments. * Remove contract's lastUpdatedTime * Remove folds activity feed * Implement getFeed cloud function * Hook up client to use getFeed * Script to cache viewCounts and lastViewTime * Batched wait all userRecommendations * Cache view script runs on all users * Update user feed each hour and get feed from cache doc. * Delete view cache script * Update feed script * Tweak feed algorithm * Compute recommendation scores from updateUserFeed * Disable lastViewedScore factor * Update lastCommentTime script * Comment out console.log * Fix timeout issue by calling new cloud functions with part of the work. * Listen for contract updates to feed. * Handle new user: use default feed of top markets this week * Track lastUpdatedTime * Tweak logic of calling cloud functions in batches * Tweak cloud function batching --- common/contract.ts | 4 +- common/new-contract.ts | 1 - common/recommended-contracts.ts | 89 +++++++ firestore.rules | 4 + functions/src/index.ts | 4 + functions/src/on-create-bet.ts | 28 +++ functions/src/on-create-comment.ts | 11 +- functions/src/on-view.ts | 24 ++ functions/src/scripts/cache-views.ts | 78 ++++++ functions/src/scripts/update-feed.ts | 38 +++ .../src/scripts/update-last-comment-time.ts | 43 ++++ functions/src/update-feed.ts | 210 ++++++++++++++++ functions/src/update-recommendations.ts | 71 ++++++ functions/src/utils.ts | 14 +- web/components/feed/activity-feed.tsx | 44 ++-- web/components/feed/find-active-contracts.ts | 6 +- web/hooks/use-algo-feed.ts | 231 +++--------------- web/hooks/use-contracts.ts | 36 ++- web/lib/firebase/contracts.ts | 43 ++++ web/lib/firebase/users.ts | 17 +- web/pages/fold/[...slugs]/index.tsx | 52 +--- web/pages/home.tsx | 25 +- 22 files changed, 777 insertions(+), 296 deletions(-) create mode 100644 functions/src/on-create-bet.ts create mode 100644 functions/src/on-view.ts create mode 100644 functions/src/scripts/cache-views.ts create mode 100644 functions/src/scripts/update-feed.ts create mode 100644 functions/src/scripts/update-last-comment-time.ts create mode 100644 functions/src/update-feed.ts create mode 100644 functions/src/update-recommendations.ts diff --git a/common/contract.ts b/common/contract.ts index 6e362de0..82a330b5 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -20,7 +20,9 @@ export type FullContract< visibility: 'public' | 'unlisted' createdTime: number // Milliseconds since epoch - lastUpdatedTime: number // If the question or description was changed + lastUpdatedTime?: number // Updated on new bet or comment + lastBetTime?: number + lastCommentTime?: number closeTime?: number // When no more trading is allowed isResolved: boolean diff --git a/common/new-contract.ts b/common/new-contract.ts index ffd27e3f..b86ebb71 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -53,7 +53,6 @@ export function getNewContract( visibility: 'public', isResolved: false, createdTime: Date.now(), - lastUpdatedTime: Date.now(), closeTime, volume, diff --git a/common/recommended-contracts.ts b/common/recommended-contracts.ts index 7de2e501..be50c5cd 100644 --- a/common/recommended-contracts.ts +++ b/common/recommended-contracts.ts @@ -1,8 +1,12 @@ import * as _ from 'lodash' +import { Bet } from './bet' import { Contract } from './contract' +import { ClickEvent } from './tracking' import { filterDefined } from './util/array' import { addObjects } from './util/object' +export const MAX_FEED_CONTRACTS = 75 + export const getRecommendedContracts = ( contractsById: { [contractId: string]: Contract }, yourBetOnContractIds: string[] @@ -92,3 +96,88 @@ const contractsToWordFrequency = (contracts: Contract[]) => { return toFrequency(frequencySum) } + +export const getWordScores = ( + contracts: Contract[], + contractViewCounts: { [contractId: string]: number }, + clicks: ClickEvent[], + bets: Bet[] +) => { + const contractClicks = _.groupBy(clicks, (click) => click.contractId) + const contractBets = _.groupBy(bets, (bet) => bet.contractId) + + const yourContracts = contracts.filter( + (c) => + contractViewCounts[c.id] || contractClicks[c.id] || contractBets[c.id] + ) + const yourTfIdf = calculateContractTfIdf(yourContracts) + + const contractWordScores = _.mapValues( + yourTfIdf, + (wordsTfIdf, contractId) => { + const viewCount = contractViewCounts[contractId] ?? 0 + const clickCount = contractClicks[contractId]?.length ?? 0 + const betCount = contractBets[contractId]?.length ?? 0 + + const factor = + -1 * Math.log(viewCount + 1) + + 10 * Math.log(betCount + clickCount / 4 + 1) + + return _.mapValues(wordsTfIdf, (tfIdf) => tfIdf * factor) + } + ) + + const wordScores = Object.values(contractWordScores).reduce(addObjects, {}) + const minScore = Math.min(...Object.values(wordScores)) + const maxScore = Math.max(...Object.values(wordScores)) + const normalizedWordScores = _.mapValues( + wordScores, + (score) => (score - minScore) / (maxScore - minScore) + ) + + // console.log( + // 'your word scores', + // _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(0, 100), + // _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(-100) + // ) + + return normalizedWordScores +} + +export function getContractScore( + contract: Contract, + wordScores: { [word: string]: number } +) { + if (Object.keys(wordScores).length === 0) return 1 + + const wordFrequency = contractToWordFrequency(contract) + const score = _.sumBy(Object.keys(wordFrequency), (word) => { + const wordFreq = wordFrequency[word] ?? 0 + const weight = wordScores[word] ?? 0 + return wordFreq * weight + }) + + return score +} + +// Caluculate Term Frequency-Inverse Document Frequency (TF-IDF): +// https://medium.datadriveninvestor.com/tf-idf-in-natural-language-processing-8db8ef4a7736 +function calculateContractTfIdf(contracts: Contract[]) { + const contractFreq = contracts.map((c) => contractToWordFrequency(c)) + const contractWords = contractFreq.map((freq) => Object.keys(freq)) + + const wordsCount: { [word: string]: number } = {} + for (const words of contractWords) { + for (const word of words) { + wordsCount[word] = (wordsCount[word] ?? 0) + 1 + } + } + + const wordIdf = _.mapValues(wordsCount, (count) => + Math.log(contracts.length / count) + ) + const contractWordsTfIdf = _.map(contractFreq, (wordFreq) => + _.mapValues(wordFreq, (freq, word) => freq * wordIdf[word]) + ) + return _.fromPairs(contracts.map((c, i) => [c.id, contractWordsTfIdf[i]])) +} diff --git a/firestore.rules b/firestore.rules index 65783ba1..28e03e64 100644 --- a/firestore.rules +++ b/firestore.rules @@ -35,6 +35,10 @@ service cloud.firestore { allow create: if userId == request.auth.uid; } + match /private-users/{userId}/cache/feed { + allow read: if userId == request.auth.uid || isAdmin(); + } + match /contracts/{contractId} { allow read; allow update: if request.resource.data.diff(resource.data).affectedKeys() diff --git a/functions/src/index.ts b/functions/src/index.ts index f8aa50e3..3c0dc8f8 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -13,12 +13,16 @@ export * from './create-contract' export * from './create-user' export * from './create-fold' export * from './create-answer' +export * from './on-create-bet' export * from './on-create-comment' export * from './on-fold-follow' export * from './on-fold-delete' +export * from './on-view' export * from './unsubscribe' export * from './update-contract-metrics' export * from './update-user-metrics' +export * from './update-recommendations' +export * from './update-feed' export * from './backup-db' export * from './change-user-info' export * from './market-close-emails' diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts new file mode 100644 index 00000000..deaa4c4a --- /dev/null +++ b/functions/src/on-create-bet.ts @@ -0,0 +1,28 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import * as _ from 'lodash' + +import { getContract } from './utils' +import { Bet } from '../../common/bet' + +const firestore = admin.firestore() + +export const onCreateBet = functions.firestore + .document('contracts/{contractId}/bets/{betId}') + .onCreate(async (change, context) => { + const { contractId } = context.params as { + contractId: string + } + + const contract = await getContract(contractId) + if (!contract) + throw new Error('Could not find contract corresponding with bet') + + const bet = change.data() as Bet + const lastBetTime = bet.createdTime + + await firestore + .collection('contracts') + .doc(contract.id) + .update({ lastBetTime, lastUpdatedTime: Date.now() }) + }) diff --git a/functions/src/on-create-comment.ts b/functions/src/on-create-comment.ts index 02ade1fe..18fc6757 100644 --- a/functions/src/on-create-comment.ts +++ b/functions/src/on-create-comment.ts @@ -18,12 +18,19 @@ export const onCreateComment = functions.firestore } const contract = await getContract(contractId) - if (!contract) return + if (!contract) + throw new Error('Could not find contract corresponding with comment') const comment = change.data() as Comment + const lastCommentTime = comment.createdTime const commentCreator = await getUser(comment.userId) - if (!commentCreator) return + if (!commentCreator) throw new Error('Could not find contract creator') + + await firestore + .collection('contracts') + .doc(contract.id) + .update({ lastCommentTime, lastUpdatedTime: Date.now() }) let bet: Bet | undefined let answer: Answer | undefined diff --git a/functions/src/on-view.ts b/functions/src/on-view.ts new file mode 100644 index 00000000..d2f746d5 --- /dev/null +++ b/functions/src/on-view.ts @@ -0,0 +1,24 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { View } from '../../common/tracking' + +const firestore = admin.firestore() + +export const onView = functions.firestore + .document('private-users/{userId}/views/{viewId}') + .onCreate(async (snapshot, context) => { + const { userId } = context.params + + const { contractId, timestamp } = snapshot.data() as View + + await firestore + .doc(`private-users/${userId}/cache/viewCounts`) + .set( + { [contractId]: admin.firestore.FieldValue.increment(1) }, + { merge: true } + ) + + await firestore + .doc(`private-users/${userId}/cache/lastViewTime`) + .set({ [contractId]: timestamp }, { merge: true }) + }) diff --git a/functions/src/scripts/cache-views.ts b/functions/src/scripts/cache-views.ts new file mode 100644 index 00000000..c7145a1e --- /dev/null +++ b/functions/src/scripts/cache-views.ts @@ -0,0 +1,78 @@ +import * as admin from 'firebase-admin' +import * as _ from 'lodash' + +import { initAdmin } from './script-init' +initAdmin() + +import { getValues } from '../utils' +import { View } from '../../../common/tracking' +import { User } from '../../../common/user' +import { batchedWaitAll } from '../../../common/util/promise' + +const firestore = admin.firestore() + +async function cacheViews() { + console.log('Caching views') + + const users = await getValues<User>(firestore.collection('users')) + + await batchedWaitAll( + users.map((user) => () => { + console.log('Caching views for', user.username) + return cacheUserViews(user.id) + }) + ) +} + +async function cacheUserViews(userId: string) { + const views = await getValues<View>( + firestore.collection('private-users').doc(userId).collection('views') + ) + + const viewCounts: { [contractId: string]: number } = {} + for (const view of views) { + viewCounts[view.contractId] = (viewCounts[view.contractId] ?? 0) + 1 + } + + const lastViewTime: { [contractId: string]: number } = {} + for (const view of views) { + lastViewTime[view.contractId] = Math.max( + lastViewTime[view.contractId] ?? 0, + view.timestamp + ) + } + + await firestore + .doc(`private-users/${userId}/cache/viewCounts`) + .set(viewCounts, { merge: true }) + + await firestore + .doc(`private-users/${userId}/cache/lastViewTime`) + .set(lastViewTime, { merge: true }) + + console.log(viewCounts, lastViewTime) +} + +async function deleteCache() { + console.log('Deleting view cache') + + const users = await getValues<User>(firestore.collection('users')) + + await batchedWaitAll( + users.map((user) => async () => { + console.log('Deleting view cache for', user.username) + await firestore.doc(`private-users/${user.id}/cache/viewCounts`).delete() + await firestore + .doc(`private-users/${user.id}/cache/lastViewTime`) + .delete() + await firestore + .doc(`private-users/${user.id}/cache/contractScores`) + .delete() + await firestore.doc(`private-users/${user.id}/cache/wordScores`).delete() + }) + ) +} + +if (require.main === module) { + cacheViews().then(() => process.exit()) +} diff --git a/functions/src/scripts/update-feed.ts b/functions/src/scripts/update-feed.ts new file mode 100644 index 00000000..25a0b14f --- /dev/null +++ b/functions/src/scripts/update-feed.ts @@ -0,0 +1,38 @@ +import * as admin from 'firebase-admin' +import * as _ from 'lodash' + +import { initAdmin } from './script-init' +initAdmin() + +import { getValues } from '../utils' +import { User } from '../../../common/user' +import { batchedWaitAll } from '../../../common/util/promise' +import { Contract } from '../../../common/contract' +import { updateWordScores } from '../update-recommendations' +import { getFeedContracts, doUserFeedUpdate } from '../update-feed' + +const firestore = admin.firestore() + +async function updateFeed() { + console.log('Updating feed') + + const contracts = await getValues<Contract>(firestore.collection('contracts')) + const feedContracts = await getFeedContracts() + const users = await getValues<User>( + firestore.collection('users') + // .where('username', '==', 'JamesGrugett') + ) + + await batchedWaitAll( + users.map((user) => async () => { + console.log('Updating recs for', user.username) + await updateWordScores(user, contracts) + console.log('Updating feed for', user.username) + await doUserFeedUpdate(user, feedContracts) + }) + ) +} + +if (require.main === module) { + updateFeed().then(() => process.exit()) +} diff --git a/functions/src/scripts/update-last-comment-time.ts b/functions/src/scripts/update-last-comment-time.ts new file mode 100644 index 00000000..ae950fbe --- /dev/null +++ b/functions/src/scripts/update-last-comment-time.ts @@ -0,0 +1,43 @@ +import * as admin from 'firebase-admin' +import * as _ from 'lodash' + +import { initAdmin } from './script-init' +initAdmin() + +import { Contract } from '../../../common/contract' +import { getValues } from '../utils' +import { Comment } from '../../../common/comment' + +async function updateLastCommentTime() { + const firestore = admin.firestore() + console.log('Updating contracts lastCommentTime') + + const contracts = await getValues<Contract>(firestore.collection('contracts')) + + console.log('Loaded', contracts.length, 'contracts') + + for (const contract of contracts) { + const contractRef = firestore.doc(`contracts/${contract.id}`) + + const lastComments = await getValues<Comment>( + contractRef.collection('comments').orderBy('createdTime', 'desc').limit(1) + ) + + if (lastComments.length > 0) { + const lastCommentTime = lastComments[0].createdTime + console.log( + 'Updating lastCommentTime', + contract.question, + lastCommentTime + ) + + await contractRef.update({ + lastCommentTime, + } as Partial<Contract>) + } + } +} + +if (require.main === module) { + updateLastCommentTime().then(() => process.exit()) +} diff --git a/functions/src/update-feed.ts b/functions/src/update-feed.ts new file mode 100644 index 00000000..accd48e8 --- /dev/null +++ b/functions/src/update-feed.ts @@ -0,0 +1,210 @@ +import * as _ from 'lodash' +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { getValue, getValues } from './utils' +import { Contract } from '../../common/contract' +import { logInterpolation } from '../../common/util/math' +import { DAY_MS } from '../../common/util/time' +import { + getProbability, + getOutcomeProbability, + getTopAnswer, +} from '../../common/calculate' +import { Bet } from '../../common/bet' +import { Comment } from '../../common/comment' +import { User } from '../../common/user' +import { + getContractScore, + MAX_FEED_CONTRACTS, +} from '../../common/recommended-contracts' +import { callCloudFunction } from './call-cloud-function' + +const firestore = admin.firestore() + +export const updateFeed = functions.pubsub + .schedule('every 60 minutes') + .onRun(async () => { + const users = await getValues<User>(firestore.collection('users')) + + const batchSize = 100 + const userBatches: User[][] = [] + for (let i = 0; i < users.length; i += batchSize) { + userBatches.push(users.slice(i, i + batchSize)) + } + + await Promise.all( + userBatches.map(async (users) => + callCloudFunction('updateFeedBatch', { users }) + ) + ) + }) + +export const updateFeedBatch = functions.https.onCall( + async (data: { users: User[] }) => { + const { users } = data + const contracts = await getFeedContracts() + + await Promise.all(users.map((user) => doUserFeedUpdate(user, contracts))) + } +) + +export async function getFeedContracts() { + // Get contracts bet on or created in last week. + const contracts = await Promise.all([ + getValues<Contract>( + firestore + .collection('contracts') + .where('isResolved', '==', false) + .where('volume7Days', '>', 0) + ), + + getValues<Contract>( + firestore + .collection('contracts') + .where('isResolved', '==', false) + .where('createdTime', '>', Date.now() - DAY_MS * 7) + .where('volume7Days', '==', 0) + ), + ]).then(([activeContracts, inactiveContracts]) => { + const combined = [...activeContracts, ...inactiveContracts] + // Remove closed contracts. + return combined.filter((c) => (c.closeTime ?? Infinity) > Date.now()) + }) + + return contracts +} + +export const doUserFeedUpdate = async (user: User, contracts: Contract[]) => { + const userCacheCollection = firestore.collection( + `private-users/${user.id}/cache` + ) + const [wordScores, lastViewedTime] = await Promise.all([ + getValue<{ [word: string]: number }>(userCacheCollection.doc('wordScores')), + getValue<{ [contractId: string]: number }>( + userCacheCollection.doc('lastViewTime') + ), + ]).then((dicts) => dicts.map((dict) => dict ?? {})) + + const scoredContracts = contracts.map((contract) => { + const score = scoreContract( + contract, + wordScores, + lastViewedTime[contract.id] + ) + return [contract, score] as [Contract, number] + }) + + const sortedContracts = _.sortBy( + scoredContracts, + ([_, score]) => score + ).reverse() + + // console.log(sortedContracts.map(([c, score]) => c.question + ': ' + score)) + + const feedContracts = sortedContracts + .slice(0, MAX_FEED_CONTRACTS) + .map(([c]) => c) + + const feed = await Promise.all( + feedContracts.map((contract) => getRecentBetsAndComments(contract)) + ) + + await userCacheCollection.doc('feed').set({ feed }) +} + +function scoreContract( + contract: Contract, + wordScores: { [word: string]: number }, + viewTime: number | undefined +) { + const recommendationScore = getContractScore(contract, wordScores) + const activityScore = getActivityScore(contract, viewTime) + // const lastViewedScore = getLastViewedScore(viewTime) + return recommendationScore * activityScore +} + +function getActivityScore(contract: Contract, viewTime: number | undefined) { + const { createdTime, lastBetTime, lastCommentTime, outcomeType } = contract + const hasNewComments = + lastCommentTime && (!viewTime || lastCommentTime > viewTime) + const newCommentScore = hasNewComments ? 1 : 0.5 + + const timeSinceLastComment = Date.now() - (lastCommentTime ?? createdTime) + const commentDaysAgo = timeSinceLastComment / DAY_MS + const commentTimeScore = + 0.25 + 0.75 * (1 - logInterpolation(0, 3, commentDaysAgo)) + + const timeSinceLastBet = Date.now() - (lastBetTime ?? createdTime) + const betDaysAgo = timeSinceLastBet / DAY_MS + const betTimeScore = 0.5 + 0.5 * (1 - logInterpolation(0, 3, betDaysAgo)) + + let prob = 0.5 + if (outcomeType === 'BINARY') { + prob = getProbability(contract) + } else if (outcomeType === 'FREE_RESPONSE') { + const topAnswer = getTopAnswer(contract) + if (topAnswer) + prob = Math.max(0.5, getOutcomeProbability(contract, topAnswer.id)) + } + const frac = 1 - Math.abs(prob - 0.5) ** 2 / 0.25 + const probScore = 0.5 + frac * 0.5 + + const { volume24Hours, volume7Days } = contract + const combinedVolume = Math.log(volume24Hours + 1) + Math.log(volume7Days + 1) + const volumeScore = 0.5 + 0.5 * logInterpolation(4, 20, combinedVolume) + + const score = + newCommentScore * commentTimeScore * betTimeScore * probScore * volumeScore + + // Map score to [0.5, 1] since no recent activty is not a deal breaker. + const mappedScore = 0.5 + 0.5 * score + const newMappedScore = 0.7 + 0.3 * score + + const isNew = Date.now() < contract.createdTime + DAY_MS + return isNew ? newMappedScore : mappedScore +} + +function getLastViewedScore(viewTime: number | undefined) { + if (viewTime === undefined) { + return 1 + } + + const daysAgo = (Date.now() - viewTime) / DAY_MS + + if (daysAgo < 0.5) { + const frac = logInterpolation(0, 0.5, daysAgo) + return 0.5 + 0.25 * frac + } + + const frac = logInterpolation(0.5, 14, daysAgo) + return 0.75 + 0.25 * frac +} + +async function getRecentBetsAndComments(contract: Contract) { + const contractDoc = firestore.collection('contracts').doc(contract.id) + + const [recentBets, recentComments] = await Promise.all([ + getValues<Bet>( + contractDoc + .collection('bets') + .where('createdTime', '>', Date.now() - DAY_MS) + .orderBy('createdTime', 'desc') + .limit(1) + ), + + getValues<Comment>( + contractDoc + .collection('comments') + .where('createdTime', '>', Date.now() - 3 * DAY_MS) + .orderBy('createdTime', 'desc') + .limit(3) + ), + ]) + + return { + contract, + recentBets, + recentComments, + } +} diff --git a/functions/src/update-recommendations.ts b/functions/src/update-recommendations.ts new file mode 100644 index 00000000..4e656dda --- /dev/null +++ b/functions/src/update-recommendations.ts @@ -0,0 +1,71 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import * as _ from 'lodash' + +import { getValue, getValues } from './utils' +import { Contract } from '../../common/contract' +import { Bet } from '../../common/bet' +import { User } from '../../common/user' +import { ClickEvent } from '../../common/tracking' +import { getWordScores } from '../../common/recommended-contracts' +import { batchedWaitAll } from '../../common/util/promise' +import { callCloudFunction } from './call-cloud-function' + +const firestore = admin.firestore() + +export const updateRecommendations = functions.pubsub + .schedule('every 24 hours') + .onRun(async () => { + const users = await getValues<User>(firestore.collection('users')) + + const batchSize = 100 + const userBatches: User[][] = [] + for (let i = 0; i < users.length; i += batchSize) { + userBatches.push(users.slice(i, i + batchSize)) + } + + await Promise.all( + userBatches.map((batch) => + callCloudFunction('updateRecommendationsBatch', { users: batch }) + ) + ) + }) + +export const updateRecommendationsBatch = functions.https.onCall( + async (data: { users: User[] }) => { + const { users } = data + + const contracts = await getValues<Contract>( + firestore.collection('contracts') + ) + + await batchedWaitAll( + users.map((user) => () => updateWordScores(user, contracts)) + ) + } +) + +export const updateWordScores = async (user: User, contracts: Contract[]) => { + const [bets, viewCounts, clicks] = await Promise.all([ + getValues<Bet>( + firestore.collectionGroup('bets').where('userId', '==', user.id) + ), + + getValue<{ [contractId: string]: number }>( + firestore.doc(`private-users/${user.id}/cache/viewCounts`) + ), + + getValues<ClickEvent>( + firestore + .collection(`private-users/${user.id}/events`) + .where('type', '==', 'click') + ), + ]) + + const wordScores = getWordScores(contracts, viewCounts ?? {}, clicks, bets) + + const cachedCollection = firestore.collection( + `private-users/${user.id}/cache` + ) + await cachedCollection.doc('wordScores').set(wordScores) +} diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 88c25570..28ef5445 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -6,27 +6,33 @@ import { PrivateUser, User } from '../../common/user' export const isProd = admin.instanceId().app.options.projectId === 'mantic-markets' -export const getValue = async <T>(collection: string, doc: string) => { +export const getDoc = async <T>(collection: string, doc: string) => { const snap = await admin.firestore().collection(collection).doc(doc).get() return snap.exists ? (snap.data() as T) : undefined } +export const getValue = async <T>(ref: admin.firestore.DocumentReference) => { + const snap = await ref.get() + + return snap.exists ? (snap.data() as T) : undefined +} + export const getValues = async <T>(query: admin.firestore.Query) => { const snap = await query.get() return snap.docs.map((doc) => doc.data() as T) } export const getContract = (contractId: string) => { - return getValue<Contract>('contracts', contractId) + return getDoc<Contract>('contracts', contractId) } export const getUser = (userId: string) => { - return getValue<User>('users', userId) + return getDoc<User>('users', userId) } export const getPrivateUser = (userId: string) => { - return getValue<PrivateUser>('private-users', userId) + return getDoc<PrivateUser>('private-users', userId) } export const getUserByUsername = async (username: string) => { diff --git a/web/components/feed/activity-feed.tsx b/web/components/feed/activity-feed.tsx index d7e3ab99..19ec1299 100644 --- a/web/components/feed/activity-feed.tsx +++ b/web/components/feed/activity-feed.tsx @@ -8,31 +8,27 @@ import { useUser } from '../../hooks/use-user' import { ContractActivity } from './contract-activity' export function ActivityFeed(props: { - contracts: Contract[] - recentBets: Bet[] - recentComments: Comment[] + feed: { + contract: Contract + recentBets: Bet[] + recentComments: Comment[] + }[] mode: 'only-recent' | 'abbreviated' | 'all' getContractPath?: (contract: Contract) => string }) { - const { contracts, recentBets, recentComments, mode, getContractPath } = props + const { feed, mode, getContractPath } = props const user = useUser() - const groupedBets = _.groupBy(recentBets, (bet) => bet.contractId) - const groupedComments = _.groupBy( - recentComments, - (comment) => comment.contractId - ) - return ( <FeedContainer - contracts={contracts} - renderContract={(contract) => ( + feed={feed} + renderItem={({ contract, recentBets, recentComments }) => ( <ContractActivity user={user} contract={contract} - bets={groupedBets[contract.id] ?? []} - comments={groupedComments[contract.id] ?? []} + bets={recentBets} + comments={recentComments} mode={mode} contractPath={getContractPath ? getContractPath(contract) : undefined} /> @@ -42,18 +38,26 @@ export function ActivityFeed(props: { } function FeedContainer(props: { - contracts: Contract[] - renderContract: (contract: Contract) => any + feed: { + contract: Contract + recentBets: Bet[] + recentComments: Comment[] + }[] + renderItem: (item: { + contract: Contract + recentBets: Bet[] + recentComments: Comment[] + }) => any }) { - const { contracts, renderContract } = props + const { feed, renderItem } = props return ( <Col className="items-center"> <Col className="w-full max-w-3xl"> <Col className="w-full divide-y divide-gray-300 self-center bg-white"> - {contracts.map((contract) => ( - <div key={contract.id} className="py-6 px-2 sm:px-4"> - {renderContract(contract)} + {feed.map((item) => ( + <div key={item.contract.id} className="py-6 px-2 sm:px-4"> + {renderItem(item)} </div> ))} </Col> diff --git a/web/components/feed/find-active-contracts.ts b/web/components/feed/find-active-contracts.ts index 42737f47..6f40806f 100644 --- a/web/components/feed/find-active-contracts.ts +++ b/web/components/feed/find-active-contracts.ts @@ -9,11 +9,7 @@ const MAX_ACTIVE_CONTRACTS = 75 // TODO: Maybe store last activity time directly in the contract? // Pros: simplifies this code; cons: harder to tweak "activity" definition later function lastActivityTime(contract: Contract) { - return Math.max( - contract.resolutionTime || 0, - contract.lastUpdatedTime, - contract.createdTime - ) + return Math.max(contract.resolutionTime || 0, contract.createdTime) } // Types of activity to surface: diff --git a/web/hooks/use-algo-feed.ts b/web/hooks/use-algo-feed.ts index e8d6396b..b8cfb7a2 100644 --- a/web/hooks/use-algo-feed.ts +++ b/web/hooks/use-algo-feed.ts @@ -1,213 +1,60 @@ import _ from 'lodash' -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect } from 'react' import { Bet } from '../../common/bet' import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' -import { User } from '../../common/user' -import { logInterpolation } from '../../common/util/math' -import { getRecommendedContracts } from '../../common/recommended-contracts' -import { useSeenContracts } from './use-seen-contracts' -import { useGetUserBetContractIds, useUserBetContracts } from './use-user-bets' -import { DAY_MS } from '../../common/util/time' -import { - getProbability, - getOutcomeProbability, - getTopAnswer, -} from '../../common/calculate' import { useTimeSinceFirstRender } from './use-time-since-first-render' import { trackLatency } from '../lib/firebase/tracking' +import { User } from '../../common/user' +import { getUserFeed } from '../lib/firebase/users' +import { useUpdatedContracts } from './use-contracts' +import { + getRecentBetsAndComments, + getTopWeeklyContracts, +} from '../lib/firebase/contracts' -const MAX_FEED_CONTRACTS = 75 +type feed = { + contract: Contract + recentBets: Bet[] + recentComments: Comment[] +}[] -export const useAlgoFeed = ( - user: User | null | undefined, - contracts: Contract[] | undefined, - recentBets: Bet[] | undefined, - recentComments: Comment[] | undefined -) => { - const initialContracts = useMemo(() => contracts, [!!contracts]) - const initialBets = useMemo(() => recentBets, [!!recentBets]) - const initialComments = useMemo(() => recentComments, [!!recentComments]) - - const yourBetContractIds = useGetUserBetContractIds(user?.id) - // Update user bet contracts in local storage. - useUserBetContracts(user?.id) - - const seenContracts = useSeenContracts() - - const [algoFeed, setAlgoFeed] = useState<Contract[]>([]) +export const useAlgoFeed = (user: User | null | undefined) => { + const [feed, setFeed] = useState<feed>() const getTime = useTimeSinceFirstRender() useEffect(() => { - if ( - initialContracts && - initialBets && - initialComments && - yourBetContractIds - ) { - const eligibleContracts = initialContracts.filter( - (c) => !c.isResolved && (c.closeTime ?? Infinity) > Date.now() - ) - const contracts = getAlgoFeed( - eligibleContracts, - initialBets, - initialComments, - yourBetContractIds, - seenContracts - ) - setAlgoFeed(contracts) - trackLatency('feed', getTime()) + if (user) { + getUserFeed(user.id).then((feed) => { + if (feed.length === 0) { + getDefaultFeed().then((feed) => setFeed(feed)) + } else setFeed(feed) + + trackLatency('feed', getTime()) + console.log('feed load time', getTime()) + }) } - }, [ - initialBets, - initialComments, - initialContracts, - seenContracts, - yourBetContractIds, - getTime, - ]) + }, [user?.id]) - return algoFeed + return useUpdateFeed(feed) } -const getAlgoFeed = ( - contracts: Contract[], - recentBets: Bet[], - recentComments: Comment[], - yourBetContractIds: string[], - seenContracts: { [contractId: string]: number } -) => { - const contractsById = _.keyBy(contracts, (c) => c.id) +const useUpdateFeed = (feed: feed | undefined) => { + const contracts = useUpdatedContracts(feed?.map((item) => item.contract)) - const recommended = getRecommendedContracts(contractsById, yourBetContractIds) - const confidence = logInterpolation(0, 100, yourBetContractIds.length) - const recommendedScores = _.fromPairs( - recommended.map((c, index) => { - const score = 1 - index / recommended.length - const withConfidence = score * confidence + (1 - confidence) - return [c.id, withConfidence] as [string, number] - }) - ) - - const seenScores = _.fromPairs( - contracts.map( - (c) => [c.id, getSeenContractsScore(c, seenContracts)] as [string, number] - ) - ) - - const activityScores = getContractsActivityScores( - contracts, - recentComments, - recentBets, - seenContracts - ) - - const combinedScores = contracts.map((contract) => { - const score = - (recommendedScores[contract.id] ?? 0) * - (seenScores[contract.id] ?? 0) * - (activityScores[contract.id] ?? 0) - return { contract, score } - }) - - const sorted = _.sortBy(combinedScores, (c) => -c.score) - return sorted.map((c) => c.contract).slice(0, MAX_FEED_CONTRACTS) + return feed && contracts + ? feed.map(({ contract, ...other }, i) => ({ + ...other, + contract: contracts[i], + })) + : undefined } -function getContractsActivityScores( - contracts: Contract[], - recentComments: Comment[], - recentBets: Bet[], - seenContracts: { [contractId: string]: number } -) { - const contractBets = _.groupBy(recentBets, (bet) => bet.contractId) - const contractMostRecentBet = _.mapValues( - contractBets, - (bets) => _.maxBy(bets, (bet) => bet.createdTime) as Bet +const getDefaultFeed = async () => { + const contracts = await getTopWeeklyContracts() + const feed = await Promise.all( + contracts.map((c) => getRecentBetsAndComments(c)) ) - - const contractComments = _.groupBy( - recentComments, - (comment) => comment.contractId - ) - const contractMostRecentComment = _.mapValues( - contractComments, - (comments) => _.maxBy(comments, (c) => c.createdTime) as Comment - ) - - const scoredContracts = contracts.map((contract) => { - const { outcomeType } = contract - - const seenTime = seenContracts[contract.id] - const lastCommentTime = contractMostRecentComment[contract.id]?.createdTime - const hasNewComments = - !seenTime || (lastCommentTime && lastCommentTime > seenTime) - const newCommentScore = hasNewComments ? 1 : 0.5 - - const commentCount = contractComments[contract.id]?.length ?? 0 - const betCount = contractBets[contract.id]?.length ?? 0 - const activtyCount = betCount + commentCount * 5 - const activityCountScore = - 0.5 + 0.5 * logInterpolation(0, 200, activtyCount) - - const { volume7Days, volume } = contract - const combinedVolume = Math.log(volume7Days + 1) + Math.log(volume + 1) - const volumeScore = 0.5 + 0.5 * logInterpolation(4, 25, combinedVolume) - - const lastBetTime = - contractMostRecentBet[contract.id]?.createdTime ?? contract.createdTime - const timeSinceLastBet = Date.now() - lastBetTime - const daysAgo = timeSinceLastBet / DAY_MS - const timeAgoScore = 1 - logInterpolation(0, 3, daysAgo) - - let prob = 0.5 - if (outcomeType === 'BINARY') { - prob = getProbability(contract) - } else if (outcomeType === 'FREE_RESPONSE') { - const topAnswer = getTopAnswer(contract) - if (topAnswer) - prob = Math.max(0.5, getOutcomeProbability(contract, topAnswer.id)) - } - const frac = 1 - Math.abs(prob - 0.5) ** 2 / 0.25 - const probScore = 0.5 + frac * 0.5 - - const score = - newCommentScore * - activityCountScore * - volumeScore * - timeAgoScore * - probScore - - // Map score to [0.5, 1] since no recent activty is not a deal breaker. - const mappedScore = 0.5 + score / 2 - const newMappedScore = 0.75 + score / 4 - - const isNew = Date.now() < contract.createdTime + DAY_MS - const activityScore = isNew ? newMappedScore : mappedScore - - return [contract.id, activityScore] as [string, number] - }) - - return _.fromPairs(scoredContracts) -} - -function getSeenContractsScore( - contract: Contract, - seenContracts: { [contractId: string]: number } -) { - const lastSeen = seenContracts[contract.id] - if (lastSeen === undefined) { - return 1 - } - - const daysAgo = (Date.now() - lastSeen) / DAY_MS - - if (daysAgo < 0.5) { - const frac = logInterpolation(0, 0.5, daysAgo) - return 0.5 * frac - } - - const frac = logInterpolation(0.5, 14, daysAgo) - return 0.5 + 0.5 * frac + return feed } diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index c6d2be0e..0402613f 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -1,8 +1,9 @@ import _ from 'lodash' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Contract, listenForActiveContracts, + listenForContract, listenForContracts, listenForHotContracts, listenForInactiveContracts, @@ -71,3 +72,36 @@ 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 = _.fromPairs(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 +} diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index f41d6902..f1ab7bba 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -23,6 +23,9 @@ import { createRNG, shuffle } from '../../../common/util/random' import { getCpmmProbability } from '../../../common/calculate-cpmm' import { formatMoney, formatPercent } from '../../../common/util/format' import { DAY_MS } from '../../../common/util/time' +import { MAX_FEED_CONTRACTS } from '../../../common/recommended-contracts' +import { Bet } from '../../../common/bet' +import { Comment } from '../../../common/comment' export type { Contract } export function contractPath(contract: Contract) { @@ -231,6 +234,16 @@ export async function getHotContracts() { ) } +const topWeeklyQuery = query( + contractCollection, + where('isResolved', '==', false), + orderBy('volume7Days', 'desc'), + limit(MAX_FEED_CONTRACTS) +) +export async function getTopWeeklyContracts() { + return await getValues<Contract>(topWeeklyQuery) +} + const closingSoonQuery = query( contractCollection, where('isResolved', '==', false), @@ -276,3 +289,33 @@ export async function getDailyContracts( return contractsByDay } + +export async function getRecentBetsAndComments(contract: Contract) { + const contractDoc = doc(db, 'contracts', contract.id) + + const [recentBets, recentComments] = await Promise.all([ + getValues<Bet>( + query( + collection(contractDoc, 'bets'), + where('createdTime', '>', Date.now() - DAY_MS), + orderBy('createdTime', 'desc'), + limit(1) + ) + ), + + getValues<Comment>( + query( + collection(contractDoc, 'comments'), + where('createdTime', '>', Date.now() - 3 * DAY_MS), + orderBy('createdTime', 'desc'), + limit(3) + ) + ), + ]) + + return { + contract, + recentBets, + recentComments, + } +} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index e7805626..e07d138c 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -23,8 +23,11 @@ import _ from 'lodash' import { app } from './init' import { PrivateUser, User } from '../../../common/user' import { createUser } from './api-call' -import { getValues, listenForValue, listenForValues } from './utils' +import { getValue, getValues, listenForValue, listenForValues } from './utils' import { DAY_MS } from '../../../common/util/time' +import { Contract } from './contracts' +import { Bet } from './bets' +import { Comment } from './comments' export type { User } @@ -207,3 +210,15 @@ export async function getDailyNewUsers( return usersByDay } + +export async function getUserFeed(userId: string) { + const feedDoc = doc(db, 'private-users', userId, 'cache', 'feed') + const userFeed = await getValue<{ + feed: { + contract: Contract + recentBets: Bet[] + recentComments: Comment[] + }[] + }>(feedDoc) + return userFeed?.feed ?? [] +} diff --git a/web/pages/fold/[...slugs]/index.tsx b/web/pages/fold/[...slugs]/index.tsx index 79cd6340..b0d52793 100644 --- a/web/pages/fold/[...slugs]/index.tsx +++ b/web/pages/fold/[...slugs]/index.tsx @@ -1,5 +1,4 @@ import _ from 'lodash' -import Link from 'next/link' import { Fold } from '../../../../common/fold' import { Comment } from '../../../../common/comment' @@ -23,22 +22,17 @@ import { useUser } from '../../../hooks/use-user' import { useFold } from '../../../hooks/use-fold' import { SearchableGrid } from '../../../components/contract/contracts-list' import { useRouter } from 'next/router' -import clsx from 'clsx' import { scoreCreators, scoreTraders } from '../../../../common/scoring' import { Leaderboard } from '../../../components/leaderboard' -import { formatMoney, toCamelCase } from '../../../../common/util/format' +import { formatMoney } from '../../../../common/util/format' import { EditFoldButton } from '../../../components/folds/edit-fold-button' import Custom404 from '../../404' import { FollowFoldButton } from '../../../components/folds/follow-fold-button' -import FeedCreate from '../../../components/feed-create' import { SEO } from '../../../components/SEO' import { useTaggedContracts } from '../../../hooks/use-contracts' import { Linkify } from '../../../components/linkify' import { fromPropz, usePropz } from '../../../hooks/use-propz' import { filterDefined } from '../../../../common/util/array' -import { useRecentBets } from '../../../hooks/use-bets' -import { useRecentComments } from '../../../hooks/use-comments' -import { LoadingIndicator } from '../../../components/loading-indicator' import { findActiveContracts } from '../../../components/feed/find-active-contracts' import { Tabs } from '../../../components/layout/tabs' @@ -149,12 +143,6 @@ export default function FoldPage(props: { const contracts = filterDefined( props.contracts.map((contract) => contractsMap[contract.id]) ) - const activeContracts = filterDefined( - props.activeContracts.map((contract) => contractsMap[contract.id]) - ) - - const recentBets = useRecentBets() - const recentComments = useRecentComments() if (fold === null || !foldSubpages.includes(page) || slugs[2]) { return <Custom404 /> @@ -178,37 +166,6 @@ export default function FoldPage(props: { </Col> ) - const activityTab = ( - <Col className="flex-1"> - {user !== null && !fold.disallowMarketCreation && ( - <FeedCreate - className={clsx('border-b-2')} - user={user} - tag={toCamelCase(fold.name)} - placeholder={`Type your question about ${fold.name}`} - /> - )} - {recentBets && recentComments ? ( - <> - <ActivityFeed - contracts={activeContracts} - recentBets={recentBets ?? []} - recentComments={recentComments ?? []} - mode="abbreviated" - /> - {activeContracts.length === 0 && ( - <div className="mx-2 mt-4 text-gray-500 lg:mx-0"> - No activity from matching markets.{' '} - {isCurator && 'Try editing to add more tags!'} - </div> - )} - </> - ) : ( - <LoadingIndicator className="mt-4" /> - )} - </Col> - ) - const leaderboardsTab = ( <Col className="gap-8 px-4 lg:flex-row"> <FoldLeaderboards @@ -248,13 +205,8 @@ export default function FoldPage(props: { </div> <Tabs - defaultIndex={page === 'leaderboards' ? 2 : page === 'markets' ? 1 : 0} + defaultIndex={page === 'leaderboards' ? 1 : 0} tabs={[ - { - title: 'Activity', - content: activityTab, - href: foldPath(fold), - }, { title: 'Markets', content: <SearchableGrid contracts={contracts} />, diff --git a/web/pages/home.tsx b/web/pages/home.tsx index d593935c..6f1ec93c 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -9,30 +9,19 @@ import { Spacer } from '../components/layout/spacer' import { Col } from '../components/layout/col' import { useUser } from '../hooks/use-user' import { LoadingIndicator } from '../components/loading-indicator' -import { useRecentBets } from '../hooks/use-bets' -import { useActiveContracts } from '../hooks/use-contracts' -import { useRecentComments } from '../hooks/use-comments' import { useAlgoFeed } from '../hooks/use-algo-feed' import { ContractPageContent } from './[username]/[contractSlug]' const Home = () => { const user = useUser() - const contracts = useActiveContracts() - const contractsDict = _.keyBy(contracts, 'id') - - const recentBets = useRecentBets() - const recentComments = useRecentComments() - - const feedContracts = useAlgoFeed(user, contracts, recentBets, recentComments) - - const updatedContracts = feedContracts.map( - (contract) => contractsDict[contract.id] ?? contract - ) + const feed = useAlgoFeed(user) const router = useRouter() const { u: username, s: slug } = router.query - const contract = feedContracts.find((c) => c.slug === slug) + const contract = feed?.find( + ({ contract }) => contract.slug === slug + )?.contract useEffect(() => { // If the page initially loads with query params, redirect to the contract page. @@ -54,11 +43,9 @@ const Home = () => { <Col className="w-full max-w-[700px]"> <FeedCreate user={user ?? undefined} /> <Spacer h={10} /> - {contracts && recentBets && recentComments ? ( + {feed ? ( <ActivityFeed - contracts={updatedContracts} - recentBets={recentBets} - recentComments={recentComments} + feed={feed} mode="only-recent" getContractPath={(c) => `home?u=${c.creatorUsername}&s=${c.slug}` From 1fa214ed48a2e5f5a3daf5559a5ef858ec81aade Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 2 May 2022 07:23:41 -0400 Subject: [PATCH 35/68] Add Givewell MIF --- common/charity.ts | 53 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/common/charity.ts b/common/charity.ts index 9754b5ea..ab769655 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -58,6 +58,42 @@ export const charities: Charity[] = [ - Promoting long-term thinking`, tags: ['Featured'] as CharityTag[], }, + { + name: 'GiveWell Maximum Impact Fund', + website: 'https://www.givewell.org/maximum-impact-fund', + photo: 'https://www.givewell.org/sites/all/themes/gw_basic/logo.png', + preview: + 'We search for the charities that save or improve lives the most per dollar.', + description: ` + GiveWell is a nonprofit dedicated to finding outstanding giving opportunities and publishing the full details of our analysis to help donors decide where to give. + + We don't focus solely on financials, such as assessing administrative or fundraising costs. Instead, we conduct in-depth research to determine how much good a given program accomplishes (in terms of lives saved, lives improved, etc.) per dollar spent. Rather than rating as many charities as possible, we focus on the few charities that stand out most (by our criteria) in order to find and confidently recommend high-impact giving opportunities (our list of top charities). + + Our top recommendation to GiveWell donors seeking to do the most good possible is to donate to the Maximum Impact Fund. Donations to the Maximum Impact Fund are granted each quarter. We use our latest research to grant the funds to the recommended charity (or charities) where we believe they’ll do the most good. + + We grant funds from the Maximum Impact Fund to the recipient charity (or charities) at the end of each fiscal quarter. Our research team decides which charities have the highest priority funding needs at that time. This decision takes into consideration factors such as: + + - Which funding gaps we expect to be filled and unfilled + - Each charity’s plans for additional funding + - The cost-effectiveness of each funding gap`, + }, + { + name: 'ARC', + website: 'https://alignment.org/', + photo: 'https://i.imgur.com/Hwg8OMP.png', + preview: 'Align future machine learning systems with human interests.', + description: `ARC is a non-profit research organization whose mission is to align future machine learning systems with human interests. Its current work focuses on developing an alignment strategy that could be adopted in industry today while scaling gracefully to future ML systems. Right now Paul Christiano and Mark Xu are researchers and Kyle Scott handles operations. + +What is “alignment”? ML systems can exhibit goal-directed behavior, but it is difficult to understand or control what they are “trying” to do. Powerful models could cause harm if they were trying to manipulate and deceive humans. The goal of intent alignment is to instead train these models to be helpful and honest. + +Motivation: We believe that modern ML techniques would lead to severe misalignment if scaled up to large enough computers and datasets. Practitioners may be able to adapt before these failures have catastrophic consequences, but we could reduce the risk by adopting scalable methods further in advance. + +What we’re working on: The best way to understand our research priorities and methodology is probably to read our report on Eliciting Latent Knowledge. At a high level, we’re trying to figure out how to train ML systems to answer questions by straightforwardly “translating” their beliefs into natural language rather than by reasoning about what a human wants to hear. + +Methodology: We’re unsatisfied with an algorithm if we can see any plausible story about how it eventually breaks down, which means that we can rule out most algorithms on paper without ever implementing them. The cost of this approach is that it may completely miss strategies that exploit important structure in realistic ML models; the benefit is that you can consider lots of ideas quickly. (More) + +Future plans: We expect to focus on similar theoretical problems in alignment until we either become more pessimistic about tractability or ARC grows enough to branch out into other areas. Over the long term we are likely to work on a combination of theoretical and empirical alignment research, collaborations with industry labs, alignment forecasting, and ML deployment policy.`, + }, { name: 'Give Directly', website: 'https://www.givedirectly.org/', @@ -114,23 +150,6 @@ export const charities: Charity[] = [ description: 'We are dedicated to ensuring our work is supported by strong evidence and remains grounded in the lessons we learn through implementation. We explore beyond current practice, to try out innovative ways – through research, implementation and policy development – to achieve effective and sustainable disease management and control.', }, - { - name: 'ARC', - website: 'https://alignment.org/', - photo: 'https://i.imgur.com/Hwg8OMP.png', - preview: 'Align future machine learning systems with human interests.', - description: `ARC is a non-profit research organization whose mission is to align future machine learning systems with human interests. Its current work focuses on developing an alignment strategy that could be adopted in industry today while scaling gracefully to future ML systems. Right now Paul Christiano and Mark Xu are researchers and Kyle Scott handles operations. - -What is “alignment”? ML systems can exhibit goal-directed behavior, but it is difficult to understand or control what they are “trying” to do. Powerful models could cause harm if they were trying to manipulate and deceive humans. The goal of intent alignment is to instead train these models to be helpful and honest. - -Motivation: We believe that modern ML techniques would lead to severe misalignment if scaled up to large enough computers and datasets. Practitioners may be able to adapt before these failures have catastrophic consequences, but we could reduce the risk by adopting scalable methods further in advance. - -What we’re working on: The best way to understand our research priorities and methodology is probably to read our report on Eliciting Latent Knowledge. At a high level, we’re trying to figure out how to train ML systems to answer questions by straightforwardly “translating” their beliefs into natural language rather than by reasoning about what a human wants to hear. - -Methodology: We’re unsatisfied with an algorithm if we can see any plausible story about how it eventually breaks down, which means that we can rule out most algorithms on paper without ever implementing them. The cost of this approach is that it may completely miss strategies that exploit important structure in realistic ML models; the benefit is that you can consider lots of ideas quickly. (More) - -Future plans: We expect to focus on similar theoretical problems in alignment until we either become more pessimistic about tractability or ARC grows enough to branch out into other areas. Over the long term we are likely to work on a combination of theoretical and empirical alignment research, collaborations with industry labs, alignment forecasting, and ML deployment policy.`, - }, { name: 'The Center for the Study of Partisanship and Ideology', website: 'https://cspicenter.org/', From b63cc176305bfdc3628584c55e7b8d46f133483a Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 2 May 2022 08:45:05 -0400 Subject: [PATCH 36/68] Hide charity names on cards --- web/components/charity/charity-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx index d1763ca8..113a58ff 100644 --- a/web/components/charity/charity-card.tsx +++ b/web/components/charity/charity-card.tsx @@ -26,7 +26,7 @@ export function CharityCard(props: { charity: Charity }) { )} </figure> <div className="card-body"> - <h3 className="card-title line-clamp-3">{name}</h3> + {/* <h3 className="card-title line-clamp-3">{name}</h3> */} <div className="line-clamp-4 text-sm">{preview}</div> {raised > 0 && ( <Row className="text-primary mt-4 flex-1 items-end justify-center gap-2"> From 0a63a0ae1fbef15c8106afe09168952603818a36 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 2 May 2022 10:14:40 -0400 Subject: [PATCH 37/68] Update README link to Discord --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b480f60c..c2883c60 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Since we are just now open-sourcing things, we will see how things go. Feel free By contributing to this codebase, you are agreeing to the terms of the [Manifold CLA](https://github.com/manifoldmarkets/manifold/blob/main/.github/CONTRIBUTING.md). -If you need additional access to any infrastructure in order to work on something (e.g. Vercel, Firebase) let us know about that on Discord as well. +If you need additional access to any infrastructure in order to work on something (e.g. Vercel, Firebase) let us know about that on [Discord][discord] as well. [vercel]: https://vercel.com/ [jamstack]: https://jamstack.org/ @@ -50,4 +50,4 @@ If you need additional access to any infrastructure in order to work on somethin [cloud-firestore]: https://firebase.google.com/docs/firestore [cloud-functions]: https://firebase.google.com/docs/functions [small-prs]: https://google.github.io/eng-practices/review/developer/small-cls.html -[discord]: https://discord.gg/eHQBNBqXuh +[discord]: https://discord.gg/3Zuth9792G From d6a9b89c43153398fab8fc2af9e78b3ef5f7fe46 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 2 May 2022 10:35:46 -0400 Subject: [PATCH 38/68] Fix answers not wrapping in cards --- web/components/outcome-label.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index b2bc2f03..95403c75 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import { Answer } from '../../common/answer' import { getProbability } from '../../common/calculate' import { @@ -126,7 +127,14 @@ export function AnswerLabel(props: { truncated = text.slice(0, 75) + '...' } - return <span className={className}>{truncated}</span> + return ( + <span + style={{ wordBreak: 'break-word' }} + className={clsx('whitespace-pre-line break-words', className)} + > + {truncated} + </span> + ) } function FreeResponseAnswerToolTip(props: { From 0b5b0bb9d31c6aa5834616464c3073f05d1caa6c Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 2 May 2022 10:43:17 -0400 Subject: [PATCH 39/68] Clear comment after submitting, for multiple bets --- web/components/feed/feed-items.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index c447f666..8d09b677 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -385,6 +385,7 @@ export function FeedBet(props: { async function submitComment() { if (!user || !comment || !canComment) return await createComment(contract.id, comment, user, id) + setComment('') } const bought = amount >= 0 ? 'bought' : 'sold' From fdbcffcfbc5d8ddfa0250c1daa8ab8717183628e Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Mon, 2 May 2022 08:23:12 -0700 Subject: [PATCH 40/68] CPM simple feed (#116) * Add minimal feed * Display full cent amount for raised < $1 --- web/components/charity/charity-card.tsx | 5 +++- web/components/charity/feed-items.tsx | 34 +++++++++++++++++++++++++ web/components/feed/feed-items.tsx | 2 +- web/pages/charity/[charitySlug].tsx | 11 +++++--- 4 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 web/components/charity/feed-items.tsx diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx index 113a58ff..bd95db73 100644 --- a/web/components/charity/charity-card.tsx +++ b/web/components/charity/charity-card.tsx @@ -3,6 +3,7 @@ import _ from 'lodash' import Link from 'next/link' import { Charity } from '../../../common/charity' import { useCharityTxns } from '../../hooks/use-charity-txns' +import { manaToUSD } from '../../pages/charity/[charitySlug]' import { Row } from '../layout/row' export function CharityCard(props: { charity: Charity }) { @@ -31,7 +32,9 @@ export function CharityCard(props: { charity: Charity }) { {raised > 0 && ( <Row className="text-primary mt-4 flex-1 items-end justify-center gap-2"> <span className="text-3xl"> - ${Math.floor((raised ?? 0) / 100)} + {raised < 100 + ? manaToUSD(raised) + : '$' + Math.floor(raised / 100)} </span> <span>raised</span> </Row> diff --git a/web/components/charity/feed-items.tsx b/web/components/charity/feed-items.tsx new file mode 100644 index 00000000..9216e776 --- /dev/null +++ b/web/components/charity/feed-items.tsx @@ -0,0 +1,34 @@ +import { Txn } from '../../../common/txn' +import { Avatar } from '../avatar' +import { useUserById } from '../../hooks/use-users' +import { UserLink } from '../user-page' +import { manaToUSD } from '../../pages/charity/[charitySlug]' +import { RelativeTimestamp } from '../feed/feed-items' + +export function Donation(props: { txn: Txn }) { + const { txn } = props + const user = useUserById(txn.fromId) + + if (!user) { + return <>Loading...</> + } + + return ( + <div className="mb-2 flow-root pr-2 md:pr-0"> + <div className="relative flex items-center space-x-3"> + <Avatar username={user.name} avatarUrl={user.avatarUrl} size="sm" /> + <div className="min-w-0 flex-1"> + <p className="mt-0.5 text-sm text-gray-500"> + <UserLink + className="text-gray-500" + username={user.username} + name={user.name} + />{' '} + donated {manaToUSD(txn.amount)} + <RelativeTimestamp time={txn.createdTime} /> + </p> + </div> + </div> + </div> + ) +} diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index 8d09b677..e21db3e5 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -310,7 +310,7 @@ export function CommentInput(props: { ) } -function RelativeTimestamp(props: { time: number }) { +export function RelativeTimestamp(props: { time: number }) { const { time } = props return ( <DateTimeTooltip time={time}> diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index b01f19e1..6c3505fb 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -17,8 +17,9 @@ import Custom404 from '../404' import { useCharityTxns } from '../../hooks/use-charity-txns' import { useWindowSize } from '../../hooks/use-window-size' import Confetti from 'react-confetti' +import { Donation } from '../../components/charity/feed-items' -const manaToUSD = (mana: number) => +export const manaToUSD = (mana: number) => (mana / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' }) export default function CharityPageWrapper() { @@ -91,6 +92,9 @@ function CharityPage(props: { charity: Charity }) { </Row> <h2 className="mt-7 mb-2 text-xl text-indigo-700">About</h2> <Blurb text={description} /> + {txns.map((txn) => ( + <Donation key={txn.id} txn={txn} /> + ))} </Col> </Col> </Page> @@ -98,8 +102,7 @@ function CharityPage(props: { charity: Charity }) { } function Blurb({ text }: { text: string }) { - // Default to open for now (aka don't actually hide any text yet.) - const [open, setOpen] = useState(true) + const [open, setOpen] = useState(false) // Calculate whether the full blurb is already shown const ref = useRef<HTMLDivElement>(null) @@ -125,7 +128,7 @@ function Blurb({ text }: { text: string }) { onClick={() => setOpen(!open)} className={clsx( 'btn btn-link capitalize-none my-3 normal-case text-indigo-700', - hideExpander && 'hidden' + hideExpander && 'invisible' )} > {open ? 'Hide' : 'Read more'} From bec8cdb3e808fa55468aa9d25c6e64e076791f67 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 2 May 2022 11:23:51 -0400 Subject: [PATCH 41/68] Add share market widget shown if you are the creator --- web/components/contract/contract-overview.tsx | 5 +++- web/components/copy-link-button.tsx | 18 +++++++------ web/components/share-market.tsx | 26 +++++++++++++++++++ web/lib/firebase/contracts.ts | 5 ++++ 4 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 web/components/share-market.tsx diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 88dc053f..524a1962 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -17,6 +17,7 @@ import { AnswersGraph } from '../answers/answers-graph' import { DPM, FreeResponse, FullContract } from '../../../common/contract' import { ContractDescription } from './contract-description' import { ContractDetails } from './contract-details' +import { ShareMarket } from '../share-market' export const ContractOverview = (props: { contract: Contract @@ -84,7 +85,9 @@ export const ContractOverview = (props: { /> )} - {contract.description && <Spacer h={6} />} + {(contract.description || isCreator) && <Spacer h={6} />} + + {isCreator && <ShareMarket className="px-2" contract={contract} />} <ContractDescription className="px-2" diff --git a/web/components/copy-link-button.tsx b/web/components/copy-link-button.tsx index 1e944311..d63d3ff2 100644 --- a/web/components/copy-link-button.tsx +++ b/web/components/copy-link-button.tsx @@ -1,6 +1,7 @@ import { Fragment } from 'react' import { LinkIcon } from '@heroicons/react/outline' import { Menu, Transition } from '@headlessui/react' +import clsx from 'clsx' import { Contract } from '../../common/contract' import { copyToClipboard } from '../lib/util/copy' import { contractPath } from '../lib/firebase/contracts' @@ -10,8 +11,11 @@ function copyContractUrl(contract: Contract) { copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`) } -export function CopyLinkButton(props: { contract: Contract }) { - const { contract } = props +export function CopyLinkButton(props: { + contract: Contract + buttonClassName?: string +}) { + const { contract, buttonClassName } = props return ( <Menu @@ -20,12 +24,10 @@ export function CopyLinkButton(props: { contract: Contract }) { onMouseUp={() => copyContractUrl(contract)} > <Menu.Button - className="btn btn-xs normal-case" - style={{ - backgroundColor: 'white', - border: '2px solid #16A34A', - color: '#16A34A', // text-green-600 - }} + className={clsx( + 'btn btn-xs border-2 border-green-600 bg-white normal-case text-green-600 hover:border-green-600 hover:bg-white', + buttonClassName + )} > <LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" /> Copy link diff --git a/web/components/share-market.tsx b/web/components/share-market.tsx new file mode 100644 index 00000000..0fa425ad --- /dev/null +++ b/web/components/share-market.tsx @@ -0,0 +1,26 @@ +import clsx from 'clsx' +import { Contract, contractUrl } from '../lib/firebase/contracts' +import { CopyLinkButton } from './copy-link-button' +import { Col } from './layout/col' +import { Row } from './layout/row' + +export function ShareMarket(props: { contract: Contract; className?: string }) { + const { contract, className } = props + + return ( + <Col className={clsx(className, 'gap-3')}> + <div>Share your market</div> + <Row className="mb-6 items-center"> + <input + className="input input-bordered flex-1 rounded-r-none text-gray-500" + type="text" + value={contractUrl(contract)} + /> + <CopyLinkButton + contract={contract} + buttonClassName="btn-md rounded-l-none" + /> + </Row> + </Col> + ) +} diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index f1ab7bba..d415788c 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -26,6 +26,7 @@ import { DAY_MS } from '../../../common/util/time' import { MAX_FEED_CONTRACTS } from '../../../common/recommended-contracts' import { Bet } from '../../../common/bet' import { Comment } from '../../../common/comment' +import { ENV_CONFIG } from '../../../common/envs/constants' export type { Contract } export function contractPath(contract: Contract) { @@ -36,6 +37,10 @@ export function homeContractPath(contract: Contract) { return `/home?c=${contract.slug}` } +export function contractUrl(contract: Contract) { + return `https://${ENV_CONFIG.domain}${contractPath(contract)}` +} + export function contractMetrics(contract: Contract) { const { createdTime, resolutionTime, isResolved } = contract From 6232284e926c034e3c1ad50ca8d39a1ef8cadd38 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 2 May 2022 12:15:00 -0400 Subject: [PATCH 42/68] Larger font on amount input --- web/components/amount-input.tsx | 2 +- web/components/answers/answer-bet-panel.tsx | 2 +- web/components/bet-panel.tsx | 34 ++++++++++++--------- web/components/resolution-panel.tsx | 4 +-- web/pages/charity/[charitySlug].tsx | 2 +- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 79bd32ad..783d8f19 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -45,7 +45,7 @@ export function AmountInput(props: { <span className="bg-gray-200 text-sm">{label}</span> <input className={clsx( - 'input input-bordered', + 'input input-bordered max-w-[200px] text-lg', error && 'input-error', inputClassName )} diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 7010de9f..9d84b2f2 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -116,7 +116,7 @@ export function AnswerBetPanel(props: { </Row> <div className="my-3 text-left text-sm text-gray-500">Amount </div> <BuyAmountInput - inputClassName="w-full" + inputClassName="w-full max-w-none" amount={betAmount} onChange={setBetAmount} error={error} diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 492a1b21..677e0197 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -62,7 +62,7 @@ export function BetPanel(props: { className )} > - <div className="mb-6 text-2xl text-gray-700">Place a trade</div> + <div className="mb-6 text-2xl">Place your bet</div> {/* <Title className={clsx('!mt-0 text-neutral')} text="Place a trade" /> */} <BuyPanel contract={contract} user={user} userBets={userBets ?? []} /> @@ -125,19 +125,23 @@ export function BetPanelSwitcher(props: { <BinaryOutcomeLabel outcome={sharesOutcome} /> shares </div> - <button - className="btn btn-sm" - style={{ - backgroundColor: 'white', - border: '2px solid', - color: '#3D4451', - }} - onClick={() => - tradeType === 'BUY' ? setTradeType('SELL') : setTradeType('BUY') - } - > - {tradeType === 'BUY' ? 'Sell' : 'Bet'} - </button> + {tradeType === 'BUY' && ( + <button + className="btn btn-sm" + style={{ + backgroundColor: 'white', + border: '2px solid', + color: '#3D4451', + }} + onClick={() => + tradeType === 'BUY' + ? setTradeType('SELL') + : setTradeType('BUY') + } + > + {tradeType === 'BUY' ? 'Sell' : 'Bet'} + </button> + )} </Row> </Col> )} @@ -299,7 +303,7 @@ function BuyPanel(props: { /> <div className="my-3 text-left text-sm text-gray-500">Amount</div> <BuyAmountInput - inputClassName="w-full" + inputClassName="w-full max-w-none" amount={betAmount} onChange={onBetChange} error={error} diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 533278e9..7645f039 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -72,9 +72,9 @@ export function ResolutionPanel(props: { return ( <Col className={clsx('rounded-md bg-white px-8 py-6', className)}> - <Title className="!mt-0 whitespace-nowrap" text="Resolve market" /> + <div className="mb-6 whitespace-nowrap text-2xl">Resolve market</div> - <div className="mb-2 text-sm text-gray-500">Outcome</div> + <div className="mb-3 text-sm text-gray-500">Outcome</div> <YesNoCancelSelector className="mx-auto my-2" diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index 6c3505fb..f7310a2f 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -207,7 +207,7 @@ function DonationBox(props: { Amount </label> <BuyAmountInput - inputClassName="w-full donate-input" + inputClassName="w-full max-w-none donate-input" amount={amount} onChange={setAmount} error={error} From 43d3662db10b6c301f79e8adcd76dbba079d6a3a Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 2 May 2022 12:16:29 -0400 Subject: [PATCH 43/68] fixed negative shares bug --- web/components/use-save-shares.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/web/components/use-save-shares.ts b/web/components/use-save-shares.ts index 467e19ae..c67ea9c9 100644 --- a/web/components/use-save-shares.ts +++ b/web/components/use-save-shares.ts @@ -26,10 +26,8 @@ export const useSaveShares = ( _.sumBy(noBets, (bet) => bet.shares), ] - const [yesFloorShares, noFloorShares] = [ - Math.floor(yesShares), - Math.floor(noShares), - ] + const yesFloorShares = Math.round(yesShares) === 0 ? 0 : Math.floor(yesShares) + const noFloorShares = Math.round(noShares) === 0 ? 0 : Math.floor(noShares) useEffect(() => { // Save yes and no shares to local storage. From beece64ae5e6416f3cc087103b3cd39e66d66b4d Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 2 May 2022 12:18:53 -0400 Subject: [PATCH 44/68] Tweak contract info dialog --- web/components/add-liquidity-panel.tsx | 4 ++- .../contract/contract-info-dialog.tsx | 30 +++++++++---------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/web/components/add-liquidity-panel.tsx b/web/components/add-liquidity-panel.tsx index 4b2ed4c1..f04c2b0a 100644 --- a/web/components/add-liquidity-panel.tsx +++ b/web/components/add-liquidity-panel.tsx @@ -56,7 +56,9 @@ export function AddLiquidityPanel(props: { contract: Contract }) { return ( <> - <div>Subsidize this market by adding liquidity for traders.</div> + <div className="text-gray-500"> + Subsidize this market by adding liquidity for traders. + </div> <Row> <AmountInput diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 79ec45ed..7d94c64a 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -49,7 +49,19 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { <Col className="gap-4 rounded bg-white p-6"> <Title className="!mt-0 !mb-0" text="Market info" /> - <div className="text-gray-500">Stats</div> + <div>Share</div> + + <Row className="justify-start gap-4"> + <CopyLinkButton contract={contract} /> + <TweetButton + className="self-start" + tweetText={getTweetText(contract, false)} + /> + <ShareEmbedButton contract={contract} /> + </Row> + <div /> + + <div>Stats</div> <table className="table-compact table-zebra table w-full text-gray-500"> <tbody> <tr> @@ -97,19 +109,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { </tbody> </table> - <div className="text-gray-500">Share</div> - - <Row className="justify-start gap-4"> - <CopyLinkButton contract={contract} /> - <TweetButton - className="self-start" - tweetText={getTweetText(contract, false)} - /> - <ShareEmbedButton contract={contract} /> - </Row> - <div /> - - <div className="text-gray-500">Tags</div> + <div>Tags</div> <TagsInput contract={contract} /> <div /> @@ -117,7 +117,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { !contract.resolution && (!closeTime || closeTime > Date.now()) && ( <> - <div className="text-gray-500">Add liquidity</div> + <div className="">Add liquidity</div> <AddLiquidityPanel contract={contract} /> </> )} From db695875c44a18c3f551b5bfaed95154ed015a32 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Mon, 2 May 2022 10:55:40 -0700 Subject: [PATCH 45/68] CPM: sort charities by amount raised (#117) * Sort charities by amount raised (after Featured) * Sort donations chronologically * refactor charities query to remove parens --- web/hooks/use-charity-txns.ts | 15 ++++++++++++++- web/lib/firebase/txns.ts | 6 ++++++ web/pages/charity/[charitySlug].tsx | 3 ++- web/pages/charity/index.tsx | 29 +++++++++++++++++++++-------- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/web/hooks/use-charity-txns.ts b/web/hooks/use-charity-txns.ts index 5636e720..c89e0b91 100644 --- a/web/hooks/use-charity-txns.ts +++ b/web/hooks/use-charity-txns.ts @@ -1,6 +1,9 @@ import { useEffect, useState } from 'react' import { Txn } from '../../common/txn' -import { listenForCharityTxns } from '../lib/firebase/txns' +import { + listenForAllCharityTxns, + listenForCharityTxns, +} from '../lib/firebase/txns' export const useCharityTxns = (charityId: string) => { const [txns, setTxns] = useState<Txn[]>([]) @@ -11,3 +14,13 @@ export const useCharityTxns = (charityId: string) => { return txns } + +export const useAllCharityTxns = () => { + const [txns, setTxns] = useState<Txn[]>([]) + + useEffect(() => { + return listenForAllCharityTxns(setTxns) + }, []) + + return txns +} diff --git a/web/lib/firebase/txns.ts b/web/lib/firebase/txns.ts index 8f9a6843..9f2b1f3b 100644 --- a/web/lib/firebase/txns.ts +++ b/web/lib/firebase/txns.ts @@ -21,3 +21,9 @@ export function listenForCharityTxns( ) { return listenForValues<Txn>(getCharityQuery(charityId), setTxns) } + +const charitiesQuery = query(txnCollection, where('toType', '==', 'CHARITY')) + +export function listenForAllCharityTxns(setTxns: (txns: Txn[]) => void) { + return listenForValues<Txn>(charitiesQuery, setTxns) +} diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index f7310a2f..8295c156 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -42,6 +42,7 @@ function CharityPage(props: { charity: Charity }) { const user = useUser() const txns = useCharityTxns(charity.id) + const newToOld = _.sortBy(txns, (txn) => -txn.createdTime) const totalRaised = _.sumBy(txns, (txn) => txn.amount) const fromYou = _.sumBy( txns.filter((txn) => txn.fromId === user?.id), @@ -92,7 +93,7 @@ function CharityPage(props: { charity: Charity }) { </Row> <h2 className="mt-7 mb-2 text-xl text-indigo-700">About</h2> <Blurb text={description} /> - {txns.map((txn) => ( + {newToOld.map((txn) => ( <Donation key={txn.id} txn={txn} /> ))} </Col> diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 5a9c77e9..6b51f7d6 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -1,26 +1,36 @@ import _ from 'lodash' import { useState, useMemo } from 'react' -import { charities as charityList } from '../../../common/charity' +import { charities } from '../../../common/charity' import { CharityCard } from '../../components/charity/charity-card' import { Col } from '../../components/layout/col' +import { Spacer } from '../../components/layout/spacer' import { Page } from '../../components/page' import { Title } from '../../components/title' - -const charities = charityList.map((charity) => ({ - ...charity, - raised: 4001, -})) +import { useAllCharityTxns } from '../../hooks/use-charity-txns' export default function Charity() { + const allCharityTxn = useAllCharityTxns() + const totals = _.mapValues(_.groupBy(allCharityTxn, 'toId'), (txns) => + _.sumBy(txns, (txn) => txn.amount) + ) + const totalRaised = _.sum(Object.values(totals)) + + // TODO: show loading state while totals are calculating + + const sortedCharities = _.sortBy(charities, [ + (charity) => (charity.tags?.includes('Featured') ? 0 : 1), + (charity) => -totals[charity.id], + ]) + const [query, setQuery] = useState('') const debouncedQuery = _.debounce(setQuery, 50) const filterCharities = useMemo( () => - charities.filter((charity) => + sortedCharities.filter((charity) => charity.name.toLowerCase().includes(query.toLowerCase()) ), - [query] + [query, sortedCharities] ) return ( @@ -31,6 +41,9 @@ export default function Charity() { <div className="mb-6 text-gray-500"> Donate your winnings to charity! Through the month of May, every M$ 100 you contribute turns into $1 USD sent to your chosen charity. + <Spacer h={5} /> + Together we've donated over ${Math.floor(totalRaised / 100)} USD so + far! </div> <input From a516122f619fcfaaddf0c266fa10ed43808261f6 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 2 May 2022 13:58:59 -0400 Subject: [PATCH 46/68] round shares to avoid negative shares error message --- common/calculate-cpmm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index d36d2f21..4c0d30eb 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -172,7 +172,7 @@ export function calculateCpmmSale( shares: number, outcome: string ) { - if (shares < 0) { + if (Math.round(shares) < 0) { throw new Error('Cannot sell non-positive shares') } From 2f6a3c4e008214a1d9e1ebd7c5a6bb7b8fdc50c1 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Mon, 2 May 2022 12:48:15 -0700 Subject: [PATCH 47/68] Replace email with free response market (#118) --- web/pages/charity/index.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 6b51f7d6..ff1925a6 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -5,6 +5,7 @@ import { CharityCard } from '../../components/charity/charity-card' import { Col } from '../../components/layout/col' import { Spacer } from '../../components/layout/spacer' import { Page } from '../../components/page' +import { SiteLink } from '../../components/site-link' import { Title } from '../../components/title' import { useAllCharityTxns } from '../../hooks/use-charity-txns' @@ -73,8 +74,14 @@ export default function Charity() { ></iframe> <div className="mt-10 text-gray-500"> - Don't see your favorite charity? Recommend that we add it by emailing - <span className="text-indigo-500"> give@manifold.markets</span>~ + Don't see your favorite charity? Recommend it{' '} + <SiteLink + href="https://manifold.markets/Sinclair/which-charities-should-manifold-add" + className="text-indigo-700" + > + here + </SiteLink> + ! <br /> <br /> <span className="italic"> From ab4dbc798c79a6aecfba147ab66a948ac297fea3 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 2 May 2022 18:18:40 -0400 Subject: [PATCH 48/68] Change analytics to 90 days. Default to DAU / MAU chart instead of DAU / WAU. --- web/pages/analytics.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pages/analytics.tsx b/web/pages/analytics.tsx index f3b1d798..ef3b89a2 100644 --- a/web/pages/analytics.tsx +++ b/web/pages/analytics.tsx @@ -18,7 +18,7 @@ import { getDailyNewUsers } from '../lib/firebase/users' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz() { - const numberOfDays = 80 + const numberOfDays = 90 const today = dayjs(dayjs().format('YYYY-MM-DD')) const startDate = today.subtract(numberOfDays, 'day') @@ -367,7 +367,7 @@ export function CustomAnalytics(props: { <Title text="Ratio of Active Users" /> <Tabs - defaultIndex={0} + defaultIndex={1} tabs={[ { title: 'Daily / Weekly', From 8da36298e5f6ee2b095351a8e19ac2e291009ede Mon Sep 17 00:00:00 2001 From: Boa <iansphilips@gmail.com> Date: Tue, 3 May 2022 07:51:25 -0600 Subject: [PATCH 49/68] condense comment logic in one component (#119) --- web/components/feed/feed-items.tsx | 151 +++++++++++++---------------- 1 file changed, 68 insertions(+), 83 deletions(-) diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index e21db3e5..a3b650c0 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -227,35 +227,27 @@ export function CommentInput(props: { const user = useUser() const [comment, setComment] = useState('') - async function submitComment() { - if (!comment) return - if (!user) { - return await firebaseLogin() - } - await createComment(contract.id, comment, user) - setComment('') - } - // Should this be oldest bet or most recent bet? const mostRecentCommentableBet = betsByCurrentUser .filter( (bet) => - canCommentOnBet(bet.userId, bet.createdTime, user) && + canCommentOnBet(bet, bet.createdTime, user) && !comments.some((comment) => comment.betId == bet.id) ) .sort((b1, b2) => b1.createdTime - b2.createdTime) .pop() - if (mostRecentCommentableBet) { - return ( - <FeedBet - contract={contract} - bet={mostRecentCommentableBet} - hideOutcome={false} - smallAvatar={false} - /> - ) + const { id } = mostRecentCommentableBet || { id: undefined } + + async function submitComment(id: string | undefined) { + if (!comment) return + if (!user) { + return await firebaseLogin() + } + await createComment(contract.id, comment, user, id) + setComment('') } + const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } = getBettorsPosition(contract, Date.now(), betsByCurrentUser) @@ -267,7 +259,14 @@ export function CommentInput(props: { </div> <div className={'min-w-0 flex-1 py-1.5'}> <div className="text-sm text-gray-500"> - {user && userPosition > 0 && ( + {mostRecentCommentableBet && ( + <BetStatusText + contract={contract} + bet={mostRecentCommentableBet} + isSelf={true} + /> + )} + {!mostRecentCommentableBet && user && userPosition > 0 && ( <> {'You have ' + userPositionMoney + ' '} <> @@ -290,7 +289,7 @@ export function CommentInput(props: { maxLength={MAX_COMMENT_LENGTH} onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - submitComment() + submitComment(id) } }} /> @@ -298,7 +297,7 @@ export function CommentInput(props: { className={ 'btn btn-outline btn-sm text-transform: mt-1 capitalize' } - onClick={submitComment} + onClick={() => submitComment(id)} > {user ? 'Comment' : 'Sign in to comment'} </button> @@ -372,24 +371,12 @@ export function FeedBet(props: { bet: Bet hideOutcome: boolean smallAvatar: boolean - hideComment?: boolean bettor?: User // If set: reveal bettor identity }) { - const { contract, bet, hideOutcome, smallAvatar, bettor, hideComment } = props - const { id, amount, outcome, createdTime, userId } = bet + const { contract, bet, hideOutcome, smallAvatar, bettor } = props + const { userId } = bet const user = useUser() const isSelf = user?.id === userId - const canComment = canCommentOnBet(userId, createdTime, user) && !hideComment - - const [comment, setComment] = useState('') - async function submitComment() { - if (!user || !comment || !canComment) return - await createComment(contract.id, comment, user, id) - setComment('') - } - - const bought = amount >= 0 ? 'bought' : 'sold' - const money = formatMoney(Math.abs(amount)) return ( <> @@ -421,52 +408,52 @@ export function FeedBet(props: { )} </div> <div className={'min-w-0 flex-1 py-1.5'}> - <div className="text-sm text-gray-500"> - <span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span>{' '} - {bought} {money} - {!hideOutcome && ( - <> - {' '} - of{' '} - <OutcomeLabel - outcome={outcome} - contract={contract} - truncate="short" - /> - </> - )} - <RelativeTimestamp time={createdTime} /> - {(canComment || comment) && ( - <div className="mt-2"> - <Textarea - value={comment} - onChange={(e) => setComment(e.target.value)} - className="textarea textarea-bordered w-full resize-none" - placeholder="Add a comment..." - rows={3} - maxLength={MAX_COMMENT_LENGTH} - onKeyDown={(e) => { - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - submitComment() - } - }} - /> - <button - className="btn btn-outline btn-sm text-transform: mt-1 capitalize" - onClick={submitComment} - disabled={!canComment} - > - Comment - </button> - </div> - )} - </div> + <BetStatusText + bet={bet} + contract={contract} + isSelf={isSelf} + hideOutcome={hideOutcome} + bettor={bettor} + /> </div> </Row> </> ) } +function BetStatusText(props: { + contract: Contract + bet: Bet + isSelf: boolean + hideOutcome?: boolean + bettor?: User +}) { + const { bet, contract, hideOutcome, bettor, isSelf } = props + const { amount, outcome, createdTime } = bet + + const bought = amount >= 0 ? 'bought' : 'sold' + const money = formatMoney(Math.abs(amount)) + + return ( + <div className="text-sm text-gray-500"> + <span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span> {bought}{' '} + {money} + {!hideOutcome && ( + <> + {' '} + of{' '} + <OutcomeLabel + outcome={outcome} + contract={contract} + truncate="short" + /> + </> + )} + <RelativeTimestamp time={createdTime} /> + </div> + ) +} + function TruncatedComment(props: { comment: string moreHref: string @@ -573,14 +560,12 @@ export function FeedQuestion(props: { ) } -function canCommentOnBet( - userId: string, - createdTime: number, - user?: User | null -) { - const isSelf = user?.id === userId +function canCommentOnBet(bet: Bet, createdTime: number, user?: User | null) { + const isSelf = user?.id === bet.userId // You can comment if your bet was posted in the last hour - return isSelf && Date.now() - createdTime < 60 * 60 * 1000 + return ( + !bet.isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000 + ) } function FeedDescription(props: { contract: Contract }) { From 3434b3de3f20ae5337102343f7906b2734c7c262 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Tue, 3 May 2022 07:00:33 -0700 Subject: [PATCH 50/68] Move all charity images to imgur + Next (#120) --- common/charity.ts | 87 +++++++++---------------- web/components/charity/charity-card.tsx | 5 +- web/next.config.js | 2 +- web/pages/charity/[charitySlug].tsx | 9 ++- 4 files changed, 39 insertions(+), 64 deletions(-) diff --git a/common/charity.ts b/common/charity.ts index ab769655..72f96983 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -19,8 +19,7 @@ export const charities: Charity[] = [ website: 'https://www.1daysooner.org/', preview: 'Accelerating the development of each vaccine by even a couple of days via COVID-19 human challenge trials could save thousands of lives.', - photo: - 'https://images.squarespace-cdn.com/content/v1/5f5f8496d1d7713486b6075a/666cbb5a-5335-4323-b9ea-b764edc826e1/OFFICIAL+1Day+Sooner+Logo.png', + photo: 'https://i.imgur.com/bUDdzUE.png', description: `1Day Sooner is a non-profit that advocates on behalf of COVID-19 challenge trial volunteers. After a vaccine candidate is created in a lab, it is developed through a combination of pre-clinical evaluation and three phases of clinical trials that test its safety and efficacy. In traditional Phase III trials, participants receive the vaccine candidate or a placebo/active comparator, and efficacy is judged by comparing the prevalence of infection in the vaccine group and the placebo/comparator group, to test the hypothesis that significantly fewer participants in the vaccine group get infected. In these traditional trials, after receiving the treatment, participants return to their homes and their normal daily lives so as to test the treatment under real world conditions. Since only a small proportion of these participants may encounter the disease, it may take a large number of participants and a good deal of time for these trials to reveal differences between the vaccine and placebo groups. @@ -35,8 +34,7 @@ export const charities: Charity[] = [ website: 'https://quantifieduncertainty.org/', preview: 'The Quantified Uncertainty Research Institute advances forecasting and epistemics to improve the long-term future of humanity.', - photo: - 'https://quantifieduncertainty.org/_next/image?url=https%3A%2F%2Fsuper-static-assets.s3.amazonaws.com%2F09bb1362-5e3f-4724-8ffd-f3235f67356f%2Fimages%2F6151ac3e-aed7-44c7-9827-399fe6e9222b.png&w=1920&q=80', + photo: 'https://i.imgur.com/ZsSXPjH.png', description: `QURI researches systematic practices to specify and estimate the most important parameters for the most important or scalable decisions. Research areas include forecasting, epistemics, evaluations, ontology, and estimation. We emphasize technological solutions that can heavily scale in the next 5 to 30 years. @@ -47,7 +45,7 @@ export const charities: Charity[] = [ { name: 'Long-Term Future Fund', website: 'https://funds.effectivealtruism.org/funds/far-future', - photo: 'https://app.effectivealtruism.org/logo-funds.svg', + photo: 'https://i.imgur.com/C2qka9g.png', preview: 'Positively influence the long-term trajectory of civilization by making grants that address global catastrophic risks.', description: `The Fund has a broad remit to make grants that promote, implement and advocate for longtermist ideas. Many of our grants aim to address potential risks from advanced artificial intelligence and to build infrastructure and advocate for longtermist projects. However, we welcome applications related to long-term institutional reform or other global catastrophic risks (e.g., pandemics or nuclear conflict). @@ -61,7 +59,7 @@ export const charities: Charity[] = [ { name: 'GiveWell Maximum Impact Fund', website: 'https://www.givewell.org/maximum-impact-fund', - photo: 'https://www.givewell.org/sites/all/themes/gw_basic/logo.png', + photo: 'https://i.imgur.com/xikuDMZ.png', preview: 'We search for the charities that save or improve lives the most per dollar.', description: ` @@ -98,8 +96,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Give Directly', website: 'https://www.givedirectly.org/', ein: '27-1661997', - photo: - 'https://www.givewell.org/sites/default/files/charity_logos/GiveDirectly.jpg', + photo: 'https://i.imgur.com/lrdxSyd.jpg', preview: 'Send money directly to people living in poverty.', description: 'GiveDirectly is a nonprofit that lets donors like you send money directly to the world’s poorest households. We believe people living in poverty deserve the dignity to choose for themselves how best to improve their lives — cash enables that choice. Since 2009, we’ve delivered $500M+ in cash directly into the hands of over 1 million families living in poverty. We currently have operations in Kenya, Rwanda, Liberia, Malawi, Morocco, Mozambique, DRC, Uganda, and the United States.', @@ -108,8 +105,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Hellen Keller International', website: 'https://www.hki.org/', ein: '13-5562162', - photo: - 'https://www.ntd-ngonetwork.org/sites/nnn/files/content/organisation/logos/2020-01-28/v2_HKLogo_Primary_RGB.jpg', + photo: 'https://i.imgur.com/Dl97Abk.jpg', preview: 'We envision a world where no one is deprived of the opportunity to live a healthy life – and reach their true potential.', description: @@ -119,8 +115,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Against Malaria Foundation', website: 'https://www.againstmalaria.com/', ein: '20-3069841', - photo: - 'https://media-exp1.licdn.com/dms/image/C4D0BAQFvdcum9KBNfg/company-logo_200_200/0?e=2159024400&v=beta&t=hxjJCKQkMp2irTOcuJEceW7x4l3c4PD7gYCQ6ulgYlg', + photo: 'https://i.imgur.com/F3JoZi9.png', preview: 'We help protect people from malaria.', description: 'AMF (againstmalaria.com) provides funding for long-lasting insecticide-treated net (LLIN) distributions (for protection against malaria) in developing countries. There is strong evidence that distributing LLINs reduces child mortality and malaria cases. AMF conducts post-distribution surveys of completed distributions to determine whether LLINs have reached their intended destinations and how long they remain in good condition.', @@ -128,8 +123,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un { name: 'Rethink Charity', website: 'https://rethink.charity/', - photo: - 'https://process.filestackapi.com/resize=width:600,height:315,fit:max/quality=value:90/jvYvq1JFQkOqo3J8hVcJ', + photo: 'https://i.imgur.com/Go7N7As.png', preview: 'Providing vital support to high-impact charities and charitable projects.', description: `At Rethink Charity, we’re excited about improving the world by providing vital support to high-impact charities and charitable projects. We equip them with tools to boost their impact, through our projects that empower their donors with tax-efficient giving options and strategically coordinated matching opportunities. @@ -143,8 +137,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Malaria Consortium', website: 'https://www.malariaconsortium.org/', ein: '98-0627052', - photo: - 'https://www.malariaconsortium.org/website-2013/images_template/malaria_consortium_logo.png', + photo: 'https://i.imgur.com/LGwy9d8.png ', preview: 'We specialise in the prevention, control and treatment of malaria and other communicable diseases.', description: @@ -153,7 +146,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un { name: 'The Center for the Study of Partisanship and Ideology', website: 'https://cspicenter.org/', - photo: 'https://cspicenter.org/wp-content/uploads/2020/02/CSPI.png', + photo: 'https://i.imgur.com/O88tkOW.png', preview: 'Support and fund research on how ideology and government policy contribute to scientific, technological, and social progress.', description: `Over the last few decades, scientific and technological progress have stagnated. Scientists conduct more research than ever before, but groundbreaking innovation is scarce. At the same time, identity politics and political polarization have reached new extremes, and social trends such as family stability and crime are worse than in previous decades and in some cases moving in the wrong direction. What explains these trends, and how can we reverse them? @@ -170,8 +163,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Faunalytics', website: 'https://faunalytics.org/', ein: '01-0686889', - photo: - 'https://animalcharityevaluators.org/wp-content/uploads/2016/08/logo-faunalytics2400x2400-200x200@2x.jpg', + photo: 'https://i.imgur.com/3JXhuXl.jpg', preview: 'Faunalytics conducts research and shares knowledge to help advocates help animals effectively.', description: @@ -181,8 +173,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'The Humane League', website: 'https://thehumaneleague.org/', ein: '04-3817491', - photo: - 'https://animalcharityevaluators.org/wp-content/uploads/2019/03/thl-mended-heart-logo@2x-200x200@2x.jpg', + photo: 'https://i.imgur.com/za9Rwon.jpg', preview: 'We exist to end the abuse of animals raised for food by influencing the policies of the world’s biggest companies, demanding legislation, and empowering others to take action and leave animals off their plates', description: @@ -192,8 +183,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Wild Animal Initiative', website: 'https://www.wildanimalinitiative.org/', ein: '82-2281466', - photo: - 'https://animalcharityevaluators.org/wp-content/uploads/2020/11/WAI-logo_square-gray-on-teal-1-630x630.png', + photo: 'https://i.imgur.com/bOVUnDm.png', preview: 'We want to make life better for wild animals.', description: 'Wild Animal Initiative (WAI) currently operates in the U.S., where they work to strengthen the animal advocacy movement through creating an academic field dedicated to wild animal welfare. They compile literature reviews, write theoretical and opinion articles, and publish research results on their website and/or in peer-reviewed journals. WAI focuses on identifying and sharing possible research avenues and connecting with more established fields. They also work with researchers from various academic and non-academic institutions to identify potential collaborators, and they recently launched a grant assistance program.', @@ -202,8 +192,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'New Incentives', website: 'https://www.newincentives.org/', ein: '45-2368993', - photo: - 'https://uploads-ssl.webflow.com/5f7c51bf9fac9b5ed62aa37b/5f7c51bf9fac9b85c42aa3df_Group%20344%20(1).svg', + photo: 'https://i.imgur.com/bYl4tk3.png', preview: 'Cash incentives to boost vaccination rates and save lives.', description: 'New Incentives (newincentives.org) runs a conditional cash transfer (CCT) program in North West Nigeria which seeks to increase uptake of routine immunizations through cash transfers, raising public awareness of the benefits of vaccination and reducing the frequency of vaccine stockouts.', @@ -212,8 +201,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'SCI foundation', website: 'https://schistosomiasiscontrolinitiative.org/', ein: '', - photo: - 'https://images.easyfundraising.org.uk/cause/cropped/cause-logo-e99e0632a8a9572150fdcf3abf08ad45.png', + photo: 'https://i.imgur.com/sWD8zM5.png', preview: 'SCI works with governments in sub-Saharan Africa to create or scale up programs that treat schistosomiasis and soil-transmitted helminthiasis ("deworming").', description: @@ -223,8 +211,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Wikimedia Foundation', website: 'https://wikimediafoundation.org/', ein: '20-0049703', - photo: - 'https://2.bp.blogspot.com/-jVseU39DW0s/VjmXVMOEEEI/AAAAAAAACK8/dwUP6sLqy-Q/s1600/wikimedia.png', + photo: 'https://i.imgur.com/klEzUbR.png', preview: 'We help everyone share in the sum of all knowledge.', description: 'We are the people who keep knowledge free. There is an amazing community of people around the world that makes great projects like Wikipedia. We help them do that work. We take care of the technical infrastructure, the legal challenges, and the growing pains.', @@ -233,8 +220,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Rainforest Trust', website: 'https://www.rainforesttrust.org/', ein: '13-3500609', - photo: - 'https://ww1.prweb.com/prfiles/2019/05/29/16344590/Rrainforest%20Trust%20new%20logo%20tall-1%20copy.png', + photo: 'https://i.imgur.com/6MzS530.png', preview: 'Rainforest Trust saves endangered wildlife and protects our planet by creating rainforest reserves through partnerships, community engagement and donor support.', description: @@ -244,8 +230,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'The Nature Conservancy', website: 'https://www.nature.org/en-us/', ein: '53-0242652', - photo: - 'https://mma.prnewswire.com/media/1140905/The_Nature_Conservancy_Logo.jpg?p=facebook', + photo: 'https://i.imgur.com/vjxkoGo.jpg', preview: 'A Future Where People and Nature Thrive', description: 'The Nature Conservancy is a global environmental nonprofit working to create a world where people and nature can thrive. Founded in the U.S. through grassroots action in 1951, The Nature Conservancy has grown to become one of the most effective and wide-reaching environmental organizations in the world. Thanks to more than a million members and the dedicated efforts of our diverse staff and over 400 scientists, we impact conservation in 76 countries and territories: 37 by direct conservation impact and 39 through partners.', @@ -254,7 +239,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Doctors Without Borders', website: 'https://www.doctorswithoutborders.org/', ein: '13-3433452', - photo: 'https://www.doctorswithoutborders.org/themes/custom/msf/logo.svg', + photo: 'https://i.imgur.com/xqhH9FE.png', preview: 'We provide independent, impartial medical humanitarian assistance to the people who need it most.', description: @@ -264,8 +249,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'World Wildlife Fund', website: 'https://www.worldwildlife.org/', ein: '52-1693387', - photo: - 'https://www.worldwildlife.org/assets/structure/unique/logo-c562409bb6158bf64e5f8b1be066dbd5983d75f5ce7c9935a5afffbcc03f8e5d.png', + photo: 'https://i.imgur.com/hDADuqW.png', preview: 'WWF works to sustain the natural world for the benefit of people and wildlife, collaborating with partners from local to global levels in nearly 100 countries.', description: @@ -274,7 +258,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un { name: 'UNICEF USA', website: 'https://www.unicefusa.org/', - photo: 'https://www.unicefusa.org/sites/default/files/UNICEFUSA_DIG_C.svg', + photo: 'https://i.imgur.com/9cxuvZi.png', ein: '13-1760110', preview: "UNICEF USA helps save and protect the world's most vulnerable children.", @@ -285,8 +269,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Vitamin Angels', website: 'https://www.vitaminangels.org/', ein: '77-0485881', - photo: - 'https://www.newhope.com/sites/newhope360.com/files/styles/article_featured_retina/public/vitamin-angels-logo.jpg?itok=pfNCPLE0', + photo: 'https://i.imgur.com/Mf35IOu.jpg', preview: 'By improving access to vital nutrition, everyone gets an equal chance to grow, thrive, and prosper.', description: @@ -296,7 +279,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Free Software Foundation', website: 'https://www.fsf.org/', ein: '04-2888848', - photo: 'https://www.gnu.org/graphics/logo-fsf.org.png', + photo: 'https://i.imgur.com/z87sFDE.png', preview: 'The Free Software Foundation (FSF) is a nonprofit with a worldwide mission to promote computer user freedom.', description: @@ -306,8 +289,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Direct Relief', website: 'https://www.directrelief.org/', ein: '95-1831116', - photo: - 'https://www.ngoadvisor.net/wp-content/uploads/2016/02/DirectRelief_Logo_RGB-2-1920x576.png', + photo: 'https://i.imgur.com/QS7kHAU.png', preview: 'Direct Relief is a humanitarian aid organization, active in all 50 states and more than 80 countries, with a mission to improve the health and lives of people affected by poverty or emergencies – without regard to politics, religion, or ability to pay.', description: @@ -317,8 +299,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'World Resources Institute', website: 'https://www.wri.org/', ein: '52-1257057', - photo: - 'https://www.americansecurityproject.org/wp-content/uploads/2016/11/WRI_logo_4c.png', + photo: 'https://i.imgur.com/Bi6MgYI.png', preview: 'WRI is a global nonprofit organization that works with leaders in government, business and civil society to research, design, and carry out practical solutions that simultaneously improve people’s lives and ensure nature can thrive.', description: @@ -328,8 +309,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'ProPublica', website: 'https://www.propublica.org/', ein: '14-2007220', - photo: - 'https://seekvectorlogo.com/wp-content/uploads/2018/09/propublica-vector-logo.png', + photo: 'https://i.imgur.com/R5Vt3Pb.png', preview: 'The mission: to expose abuses of power and betrayals of the public trust by government, business, and other institutions, using the moral force of investigative journalism to spur reform through the sustained spotlighting of wrongdoing.', description: @@ -339,8 +319,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Dana-Farber Cancer Institute', website: 'https://www.dana-farber.org/', ein: '04-2263040', - photo: - 'https://www.danafarbermasterclass.com/assets/images/DFCI-logo-lens-stacked.png', + photo: 'https://i.imgur.com/SQNn97p.png', preview: "For over 70 years, we've led the world by making life-changing breakthroughs in cancer research and patient care, providing the most advanced treatments available.", description: @@ -350,8 +329,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Save The Children', website: 'https://www.savethechildren.org/', ein: '06-0726487', - photo: - 'https://www.thisisclapham.co.uk/wp-content/uploads/2016/08/savethechildren.png', + photo: 'https://i.imgur.com/GngYPBI.png', preview: 'Through the decades, Save the Children has continued to work to save children’s lives, and that’s still what we do today.', description: @@ -361,8 +339,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'World Central Kitchen Incorporated', website: 'https://wck.org/', ein: '27-3521132', - photo: - 'https://res.cloudinary.com/dktp1ybbx/image/upload/f_auto,fl_lossy,q_auto/v1560203222/organization/prod/924457/M0oxO9vaxO.png', + photo: 'https://i.imgur.com/te93MaY.png', preview: 'WCK is first to the frontlines, providing meals in response to humanitarian, climate, and community crises. We build resilient food systems with locally led solutions.', description: @@ -372,8 +349,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'The Johns Hopkins Center for Health Security', website: 'https://www.centerforhealthsecurity.org/', ein: '', - photo: - 'https://www.centerforhealthsecurity.org/sebin/d/d/CHS.logo.horizontal.blue.png', + photo: 'https://i.imgur.com/gKZE2Xs.png', preview: 'Our mission: to protect people’s health from epidemics and disasters and ensure that communities are resilient to major challenges.', description: @@ -382,8 +358,7 @@ Future plans: We expect to focus on similar theoretical problems in alignment un { name: 'ALLFED', website: 'https://allfed.info/', - photo: - 'https://images1.the-dots.com/1860424/allfed-logo-1.png?p=projectImageFullJpg', + photo: 'https://i.imgur.com/p235vwF.jpg', ein: '27-6601178', preview: 'Feeding everyone no matter what.', description: diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx index bd95db73..1deae59f 100644 --- a/web/components/charity/charity-card.tsx +++ b/web/components/charity/charity-card.tsx @@ -1,6 +1,7 @@ import { StarIcon } from '@heroicons/react/solid' import _ from 'lodash' import Link from 'next/link' +import Image from 'next/image' import { Charity } from '../../../common/charity' import { useCharityTxns } from '../../hooks/use-charity-txns' import { manaToUSD } from '../../pages/charity/[charitySlug]' @@ -19,9 +20,9 @@ export function CharityCard(props: { charity: Charity }) { {tags?.includes('Featured') && <FeaturedBadge />} </Row> - <figure className="h-32 px-4 pt-4"> + <figure className="relative h-32 p-4"> {photo ? ( - <img className="h-full w-full object-contain" src={photo} alt="" /> + <Image src={photo} alt="" layout="fill" objectFit="contain" /> ) : ( <div className="h-full w-full bg-gradient-to-r from-slate-300 to-indigo-200" /> )} diff --git a/web/next.config.js b/web/next.config.js index a40c9e32..a03f4b93 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -8,7 +8,7 @@ module.exports = { optimizeCss: true, }, images: { - domains: ['lh3.googleusercontent.com'], + domains: ['lh3.googleusercontent.com', 'i.imgur.com'], }, async redirects() { return [ diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index 8295c156..c53cbb7e 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -18,6 +18,7 @@ import { useCharityTxns } from '../../hooks/use-charity-txns' import { useWindowSize } from '../../hooks/use-window-size' import Confetti from 'react-confetti' import { Donation } from '../../components/charity/feed-items' +import Image from 'next/image' export const manaToUSD = (mana: number) => (mana / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' }) @@ -78,11 +79,9 @@ function CharityPage(props: { charity: Charity }) { {/* TODO: donations over time chart */} <Row className="justify-between"> {photo && ( - <img - src={photo} - alt="" - className="w-40 rounded-2xl object-contain" - /> + <div className="relative w-40 rounded-2xl"> + <Image src={photo} alt="" layout="fill" objectFit="contain" /> + </div> )} <Details charity={charity} From 2da5423f361c64d3de14dfcc8a3f21bf4a2dbfcf Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Tue, 3 May 2022 08:12:42 -0700 Subject: [PATCH 51/68] Add padding too charity images (#123) --- web/components/charity/charity-card.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx index 1deae59f..2bf8f879 100644 --- a/web/components/charity/charity-card.tsx +++ b/web/components/charity/charity-card.tsx @@ -19,14 +19,15 @@ export function CharityCard(props: { charity: Charity }) { <Row className="mt-6"> {tags?.includes('Featured') && <FeaturedBadge />} </Row> - - <figure className="relative h-32 p-4"> - {photo ? ( - <Image src={photo} alt="" layout="fill" objectFit="contain" /> - ) : ( - <div className="h-full w-full bg-gradient-to-r from-slate-300 to-indigo-200" /> - )} - </figure> + <div className="px-8"> + <figure className="relative h-32"> + {photo ? ( + <Image src={photo} alt="" layout="fill" objectFit="contain" /> + ) : ( + <div className="h-full w-full bg-gradient-to-r from-slate-300 to-indigo-200" /> + )} + </figure> + </div> <div className="card-body"> {/* <h3 className="card-title line-clamp-3">{name}</h3> */} <div className="line-clamp-4 text-sm">{preview}</div> From 14544d064af3385fd0ac75d1ece93b2c3c7b3503 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 3 May 2022 11:26:02 -0400 Subject: [PATCH 52/68] charity page: remove centering --- web/pages/charity/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index ff1925a6..77b75425 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -36,7 +36,7 @@ export default function Charity() { return ( <Page> - <Col className="w-full items-center rounded px-4 py-6 sm:px-8 xl:w-[125%]"> + <Col className="w-full rounded px-4 py-6 sm:px-8 xl:w-[125%]"> <Col className="max-w-xl gap-2"> <Title className="!mt-0" text="Manifold for Good" /> <div className="mb-6 text-gray-500"> From a982b86cfe62b25d242be8043d062d90d2c3b6f5 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 3 May 2022 12:18:37 -0400 Subject: [PATCH 53/68] $300 ante for free daily markets --- functions/src/create-contract.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index ae7b0ad8..e531e1b3 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -72,7 +72,6 @@ export const createContract = functions ) return { status: 'error', message: 'Invalid initial probability' } - const ante = FIXED_ANTE // data.ante // uses utc time on server: const today = new Date().setHours(0, 0, 0, 0) const userContractsCreatedTodaySnapshot = await firestore @@ -82,6 +81,8 @@ export const createContract = functions .get() const isFree = userContractsCreatedTodaySnapshot.size === 0 + const ante = isFree ? 300 : FIXED_ANTE // data.ante + if ( ante === undefined || ante < MINIMUM_ANTE || From abf23a1462d9b1725f3f5aca6097be69df29c9f0 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Tue, 3 May 2022 10:25:14 -0700 Subject: [PATCH 54/68] Pre-load charity order to prevent "jump" (#122) --- web/hooks/use-charity-txns.ts | 15 +-------------- web/lib/firebase/txns.ts | 6 +++--- web/pages/charity/index.tsx | 32 ++++++++++++++++++++++---------- 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/web/hooks/use-charity-txns.ts b/web/hooks/use-charity-txns.ts index c89e0b91..5636e720 100644 --- a/web/hooks/use-charity-txns.ts +++ b/web/hooks/use-charity-txns.ts @@ -1,9 +1,6 @@ import { useEffect, useState } from 'react' import { Txn } from '../../common/txn' -import { - listenForAllCharityTxns, - listenForCharityTxns, -} from '../lib/firebase/txns' +import { listenForCharityTxns } from '../lib/firebase/txns' export const useCharityTxns = (charityId: string) => { const [txns, setTxns] = useState<Txn[]>([]) @@ -14,13 +11,3 @@ export const useCharityTxns = (charityId: string) => { return txns } - -export const useAllCharityTxns = () => { - const [txns, setTxns] = useState<Txn[]>([]) - - useEffect(() => { - return listenForAllCharityTxns(setTxns) - }, []) - - return txns -} diff --git a/web/lib/firebase/txns.ts b/web/lib/firebase/txns.ts index 9f2b1f3b..efb98b45 100644 --- a/web/lib/firebase/txns.ts +++ b/web/lib/firebase/txns.ts @@ -3,7 +3,7 @@ import _ from 'lodash' import { Txn } from '../../../common/txn' import { db } from './init' -import { listenForValues } from './utils' +import { getValues, listenForValues } from './utils' const txnCollection = collection(db, 'txns') @@ -24,6 +24,6 @@ export function listenForCharityTxns( const charitiesQuery = query(txnCollection, where('toType', '==', 'CHARITY')) -export function listenForAllCharityTxns(setTxns: (txns: Txn[]) => void) { - return listenForValues<Txn>(charitiesQuery, setTxns) +export function getAllCharityTxns() { + return getValues<Txn>(charitiesQuery) } diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 77b75425..39e96792 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -1,37 +1,49 @@ import _ from 'lodash' import { useState, useMemo } from 'react' -import { charities } from '../../../common/charity' +import { charities, Charity as CharityType } from '../../../common/charity' import { CharityCard } from '../../components/charity/charity-card' import { Col } from '../../components/layout/col' import { Spacer } from '../../components/layout/spacer' import { Page } from '../../components/page' import { SiteLink } from '../../components/site-link' import { Title } from '../../components/title' -import { useAllCharityTxns } from '../../hooks/use-charity-txns' +import { getAllCharityTxns } from '../../lib/firebase/txns' -export default function Charity() { - const allCharityTxn = useAllCharityTxns() - const totals = _.mapValues(_.groupBy(allCharityTxn, 'toId'), (txns) => +export async function getStaticProps() { + const txns = await getAllCharityTxns() + const totals = _.mapValues(_.groupBy(txns, 'toId'), (txns) => _.sumBy(txns, (txn) => txn.amount) ) const totalRaised = _.sum(Object.values(totals)) - - // TODO: show loading state while totals are calculating - const sortedCharities = _.sortBy(charities, [ (charity) => (charity.tags?.includes('Featured') ? 0 : 1), (charity) => -totals[charity.id], ]) + return { + props: { + totalRaised, + charities: sortedCharities, + }, + revalidate: 60, + } +} + +export default function Charity(props: { + totalRaised: number + charities: CharityType[] +}) { + const { totalRaised, charities } = props + const [query, setQuery] = useState('') const debouncedQuery = _.debounce(setQuery, 50) const filterCharities = useMemo( () => - sortedCharities.filter((charity) => + charities.filter((charity) => charity.name.toLowerCase().includes(query.toLowerCase()) ), - [query, sortedCharities] + [query] ) return ( From f2217d1d8b9febf1b9e252a41f70ebf9aa5bc6ce Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Tue, 3 May 2022 13:36:00 -0700 Subject: [PATCH 55/68] Improve donate box UI in mid-size window (#126) --- web/components/page.tsx | 2 +- web/pages/charity/[charitySlug].tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/page.tsx b/web/components/page.tsx index 6ee84875..90aa41df 100644 --- a/web/components/page.tsx +++ b/web/components/page.tsx @@ -32,7 +32,7 @@ export function Page(props: { {children} {/* If right sidebar is hidden, place its content at the bottom of the page. */} - <div className="block xl:hidden">{rightSidebar}</div> + <div className="mt-4 block xl:hidden">{rightSidebar}</div> </main> <aside className="hidden xl:col-span-3 xl:block"> <div className="sticky top-4 space-y-4">{rightSidebar}</div> diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index c53cbb7e..6869ede8 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -215,8 +215,8 @@ function DonationBox(props: { /> <Col className="mt-3 w-full gap-3"> - <Row className="items-center justify-between text-sm"> - <span className="text-gray-500">{charity.name} receives</span> + <Row className="items-center text-sm xl:justify-between"> + <span className="mr-1 text-gray-500">{charity.name} receives</span> <span>{manaToUSD(amount || 0)}</span> </Row> {/* TODO: matching pool */} From 100821e34c6bcb16eaab9855e46600c5ed49cba1 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Tue, 3 May 2022 13:36:54 -0700 Subject: [PATCH 56/68] Ask user to buy more mana when insufficient funds (#124) --- web/components/amount-input.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 783d8f19..d4724c2a 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -8,6 +8,7 @@ import { Bet } from '../../common/bet' import { Spacer } from './layout/spacer' import { calculateCpmmSale } from '../../common/calculate-cpmm' import { Binary, CPMM, FullContract } from '../../common/contract' +import { SiteLink } from './site-link' export function AmountInput(props: { amount: number | undefined @@ -65,7 +66,16 @@ export function AmountInput(props: { {error && ( <div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> - {error} + {error === 'Insufficient balance' ? ( + <> + Not enough funds. + <span className="ml-1 text-indigo-500"> + <SiteLink href="/add-funds">Buy more?</SiteLink> + </span> + </> + ) : ( + error + )} </div> )} From 3a33efa8dbaa68499f5613ce6f1a3f877bd563a0 Mon Sep 17 00:00:00 2001 From: Boa <iansphilips@gmail.com> Date: Tue, 3 May 2022 14:38:40 -0600 Subject: [PATCH 57/68] Threaded free response comments & general comments sections (#121) * Allow comments to reference answers * Add comment inputs for free response answers * condense comment logic in one component * Add General Comments section to FR answers * Prompt signin even if no comment * Remove unused & refactor * Simplify general comments logic, toggle comment boxes * Clarify rendering logic --- common/comment.ts | 1 + og-image/api/_lib/template.ts | 3 +- web/components/contract/contract-tabs.tsx | 37 +++++-- web/components/feed/activity-items.ts | 97 +++++++++++++++-- web/components/feed/contract-activity.tsx | 12 ++- web/components/feed/feed-items.tsx | 125 +++++++++++++++------- web/lib/firebase/comments.ts | 6 +- 7 files changed, 224 insertions(+), 57 deletions(-) diff --git a/common/comment.ts b/common/comment.ts index 5daeb37e..15cfbcb5 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -4,6 +4,7 @@ export type Comment = { id: string contractId: string betId?: string + answerOutcome?: string userId: string text: string diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index 00d47394..a6b0336c 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -93,7 +93,8 @@ export function getHtml(parsedReq: ParsedRequest) { creatorAvatarUrl, } = parsedReq const MAX_QUESTION_CHARS = 100 - const truncatedQuestion = question.length > MAX_QUESTION_CHARS + const truncatedQuestion = + question.length > MAX_QUESTION_CHARS ? question.slice(0, MAX_QUESTION_CHARS) + '...' : question const hideAvatar = creatorAvatarUrl ? '' : 'hidden' diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 2ba6cde8..ae1203cf 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -7,6 +7,7 @@ import { ContractActivity } from '../feed/contract-activity' import { ContractBetsTable, MyBetsSummary } from '../bets-list' import { Spacer } from '../layout/spacer' import { Tabs } from '../layout/tabs' +import { Col } from '../layout/col' export function ContractTabs(props: { contract: Contract @@ -33,14 +34,34 @@ export function ContractTabs(props: { ) const commentActivity = ( - <ContractActivity - contract={contract} - bets={bets} - comments={comments} - user={user} - mode="comments" - betRowClassName="!mt-0 xl:hidden" - /> + <> + <ContractActivity + contract={contract} + bets={bets} + comments={comments} + user={user} + mode={ + contract.outcomeType === 'FREE_RESPONSE' + ? 'free-response-comment-answer-groups' + : 'comments' + } + betRowClassName="!mt-0 xl:hidden" + /> + {contract.outcomeType === 'FREE_RESPONSE' && ( + <Col className={'mt-8 flex w-full '}> + <div className={'text-md mt-8 mb-2 text-left'}>General Comments</div> + <div className={'mb-4 w-full border-b border-gray-200'} /> + <ContractActivity + contract={contract} + bets={bets} + comments={comments} + user={user} + mode={'comments'} + betRowClassName="!mt-0 xl:hidden" + /> + </Col> + )} + </> ) const yourTrades = ( diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts index 40dd2338..71d42621 100644 --- a/web/components/feed/activity-items.ts +++ b/web/components/feed/activity-items.ts @@ -33,6 +33,7 @@ export type CommentInputItem = BaseActivityItem & { type: 'commentInput' betsByCurrentUser: Bet[] comments: Comment[] + answerOutcome?: string } export type DescriptionItem = BaseActivityItem & { @@ -82,6 +83,7 @@ export type ResolveItem = BaseActivityItem & { type: 'resolve' } +export const GENERAL_COMMENTS_OUTCOME_ID = 'General Comments' const DAY_IN_MS = 24 * 60 * 60 * 1000 const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3 @@ -263,6 +265,68 @@ function getAnswerGroups( return answerGroups } +function getAnswerAndCommentInputGroups( + contract: FullContract<DPM, FreeResponse>, + bets: Bet[], + comments: Comment[], + user: User | undefined | null +) { + let outcomes = _.uniq(bets.map((bet) => bet.outcome)).filter( + (outcome) => getOutcomeProbability(contract, outcome) > 0.0001 + ) + outcomes = _.sortBy(outcomes, (outcome) => + getOutcomeProbability(contract, outcome) + ) + + function collateCommentsSectionForOutcome(outcome: string) { + const answerBets = bets.filter((bet) => bet.outcome === outcome) + const answerComments = comments.filter( + (comment) => + comment.answerOutcome === outcome || + answerBets.some((bet) => bet.id === comment.betId) + ) + let items = [] + items.push({ + type: 'commentInput' as const, + id: 'commentInputFor' + outcome, + contract, + betsByCurrentUser: user + ? bets.filter((bet) => bet.userId === user.id) + : [], + comments: comments, + answerOutcome: outcome, + }) + items.push( + ...getCommentsWithPositions( + answerBets, + answerComments, + contract + ).reverse() + ) + return items + } + + const answerGroups = outcomes + .map((outcome) => { + const answer = contract.answers?.find( + (answer) => answer.id === outcome + ) as Answer + + const items = collateCommentsSectionForOutcome(outcome) + + return { + id: outcome, + type: 'answergroup' as const, + contract, + answer, + items, + user, + } + }) + .filter((group) => group.answer) as ActivityItem[] + return answerGroups +} + function groupBetsAndComments( bets: Bet[], comments: Comment[], @@ -382,7 +446,7 @@ export function getAllContractActivityItems( ) ) items.push({ - type: 'commentInput', + type: 'commentInput' as const, id: 'commentInput', contract, betsByCurrentUser: [], @@ -408,7 +472,7 @@ export function getAllContractActivityItems( if (outcomeType === 'BINARY') { items.push({ - type: 'commentInput', + type: 'commentInput' as const, id: 'commentInput', contract, betsByCurrentUser: [], @@ -479,7 +543,7 @@ export function getSpecificContractActivityItems( comments: Comment[], user: User | null | undefined, options: { - mode: 'comments' | 'bets' + mode: 'comments' | 'bets' | 'free-response-comment-answer-groups' } ) { const { mode } = options @@ -501,18 +565,39 @@ export function getSpecificContractActivityItems( break case 'comments': - items.push(...getCommentsWithPositions(bets, comments, contract)) + const nonFreeResponseComments = comments.filter( + (comment) => comment.answerOutcome === undefined + ) + const nonFreeResponseBets = + contract.outcomeType === 'FREE_RESPONSE' ? [] : bets + items.push( + ...getCommentsWithPositions( + nonFreeResponseBets, + nonFreeResponseComments, + contract + ) + ) items.push({ type: 'commentInput', id: 'commentInput', contract, betsByCurrentUser: user - ? bets.filter((bet) => bet.userId === user.id) + ? nonFreeResponseBets.filter((bet) => bet.userId === user.id) : [], - comments: comments, + comments: nonFreeResponseComments, }) break + case 'free-response-comment-answer-groups': + items.push( + ...getAnswerAndCommentInputGroups( + contract as FullContract<DPM, FreeResponse>, + bets, + comments, + user + ) + ) + break } return items.reverse() diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index f65c6716..a0e40916 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -16,7 +16,13 @@ export function ContractActivity(props: { bets: Bet[] comments: Comment[] user: User | null | undefined - mode: 'only-recent' | 'abbreviated' | 'all' | 'comments' | 'bets' + mode: + | 'only-recent' + | 'abbreviated' + | 'all' + | 'comments' + | 'bets' + | 'free-response-comment-answer-groups' contractPath?: string className?: string betRowClassName?: string @@ -38,7 +44,9 @@ export function ContractActivity(props: { ? getRecentContractActivityItems(contract, bets, comments, user, { contractPath, }) - : mode === 'comments' || mode === 'bets' + : mode === 'comments' || + mode === 'bets' || + mode === 'free-response-comment-answer-groups' ? getSpecificContractActivityItems(contract, bets, comments, user, { mode, }) diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index a3b650c0..3627a6b4 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -37,7 +37,7 @@ import { fromNow } from '../../lib/util/time' import BetRow from '../bet-row' import { Avatar } from '../avatar' import { Answer } from '../../../common/answer' -import { ActivityItem } from './activity-items' +import { ActivityItem, GENERAL_COMMENTS_OUTCOME_ID } from './activity-items' import { Binary, CPMM, @@ -222,29 +222,42 @@ export function CommentInput(props: { contract: Contract betsByCurrentUser: Bet[] comments: Comment[] + // Only for free response comment inputs + answerOutcome?: string }) { - const { contract, betsByCurrentUser, comments } = props + const { contract, betsByCurrentUser, comments, answerOutcome } = props const user = useUser() const [comment, setComment] = useState('') + const [focused, setFocused] = useState(false) // Should this be oldest bet or most recent bet? const mostRecentCommentableBet = betsByCurrentUser - .filter( - (bet) => - canCommentOnBet(bet, bet.createdTime, user) && + .filter((bet) => { + if ( + canCommentOnBet(bet, user) && + // The bet doesn't already have a comment !comments.some((comment) => comment.betId == bet.id) - ) + ) { + if (!answerOutcome) return true + // If we're in free response, don't allow commenting on ante bet + return ( + bet.outcome !== GENERAL_COMMENTS_OUTCOME_ID && + answerOutcome === bet.outcome + ) + } + return false + }) .sort((b1, b2) => b1.createdTime - b2.createdTime) .pop() const { id } = mostRecentCommentableBet || { id: undefined } - async function submitComment(id: string | undefined) { - if (!comment) return + async function submitComment(betId: string | undefined) { if (!user) { return await firebaseLogin() } - await createComment(contract.id, comment, user, id) + if (!comment) return + await createComment(contract.id, comment, user, betId, answerOutcome) setComment('') } @@ -253,11 +266,11 @@ export function CommentInput(props: { return ( <> - <Row className={'flex w-full gap-2 pt-3'}> + <Row className={'flex w-full gap-2'}> <div> <Avatar avatarUrl={user?.avatarUrl} username={user?.username} /> </div> - <div className={'min-w-0 flex-1 py-1.5'}> + <div className={'min-w-0 flex-1'}> <div className="text-sm text-gray-500"> {mostRecentCommentableBet && ( <BetStatusText @@ -279,30 +292,65 @@ export function CommentInput(props: { </> </> )} - <div className="mt-2"> - <Textarea - value={comment} - onChange={(e) => setComment(e.target.value)} - className="textarea textarea-bordered w-full resize-none" - placeholder="Add a comment..." - rows={3} - maxLength={MAX_COMMENT_LENGTH} - onKeyDown={(e) => { - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - submitComment(id) - } - }} - /> - <button - className={ - 'btn btn-outline btn-sm text-transform: mt-1 capitalize' - } - onClick={() => submitComment(id)} - > - {user ? 'Comment' : 'Sign in to comment'} - </button> - </div> + {(answerOutcome === undefined || focused) && ( + <div className="mt-2"> + <Textarea + value={comment} + onChange={(e) => setComment(e.target.value)} + className="textarea textarea-bordered w-full resize-none" + placeholder="Add a comment..." + autoFocus={true} + rows={answerOutcome == undefined || focused ? 3 : 1} + onFocus={() => setFocused(true)} + onBlur={() => !comment && setFocused(false)} + maxLength={MAX_COMMENT_LENGTH} + onKeyDown={(e) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + submitComment(id) + } + }} + /> + </div> + )} </div> + {!user && ( + <button + className={ + 'btn btn-outline btn-sm text-transform: mt-1 capitalize' + } + onClick={() => submitComment(id)} + > + Sign in to Comment + </button> + )} + {user && answerOutcome === undefined && ( + <button + className={ + 'btn btn-outline btn-sm text-transform: mt-1 capitalize' + } + onClick={() => submitComment(id)} + > + Comment + </button> + )} + {user && answerOutcome !== undefined && ( + <button + className={ + focused + ? 'btn btn-outline btn-sm text-transform: mt-1 capitalize' + : 'btn btn-ghost btn-sm text-transform: mt-1 capitalize' + } + onClick={() => { + if (!focused) setFocused(true) + else { + submitComment(id) + setFocused(false) + } + }} + > + {!focused ? 'Add Comment' : 'Comment'} + </button> + )} </div> </Row> </> @@ -560,12 +608,11 @@ export function FeedQuestion(props: { ) } -function canCommentOnBet(bet: Bet, createdTime: number, user?: User | null) { - const isSelf = user?.id === bet.userId +function canCommentOnBet(bet: Bet, user?: User | null) { + const { userId, createdTime, isRedemption } = bet + const isSelf = user?.id === userId // You can comment if your bet was posted in the last hour - return ( - !bet.isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000 - ) + return !isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000 } function FeedDescription(props: { contract: Contract }) { diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index 65c86621..381269d2 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -21,7 +21,8 @@ export async function createComment( contractId: string, text: string, commenter: User, - betId?: string + betId?: string, + answerOutcome?: string ) { const ref = betId ? doc(getCommentsCollection(contractId), betId) @@ -39,6 +40,9 @@ export async function createComment( if (betId) { comment.betId = betId } + if (answerOutcome) { + comment.answerOutcome = answerOutcome + } return await setDoc(ref, comment) } From 0d63e471bede6ae7f7ae1eb70d08ffa0cae3b9b1 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 3 May 2022 16:45:21 -0400 Subject: [PATCH 58/68] Only show cursor if focused --- web/components/feed/feed-items.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index 3627a6b4..472f6649 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -299,7 +299,7 @@ export function CommentInput(props: { onChange={(e) => setComment(e.target.value)} className="textarea textarea-bordered w-full resize-none" placeholder="Add a comment..." - autoFocus={true} + autoFocus={focused} rows={answerOutcome == undefined || focused ? 3 : 1} onFocus={() => setFocused(true)} onBlur={() => !comment && setFocused(false)} From 278bcb97241be24ff379c17ae7952694ff489ae4 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 3 May 2022 16:57:39 -0400 Subject: [PATCH 59/68] Show resolved FR answers --- web/pages/[username]/[contractSlug].tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 6fa84a7f..84231e5a 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -169,7 +169,7 @@ export function ContractPageContent(props: FirstArgument<typeof ContractPage>) { /> {outcomeType === 'FREE_RESPONSE' && - (!isResolved || resolution === 'MKT') && ( + (isResolved || resolution === 'MKT') && ( <> <Spacer h={4} /> <AnswersPanel @@ -179,7 +179,7 @@ export function ContractPageContent(props: FirstArgument<typeof ContractPage>) { </> )} - {contract.isResolved && ( + {isResolved && ( <> <div className="grid grid-cols-1 sm:grid-cols-2"> <ContractLeaderboard contract={contract} bets={bets} /> From a004d3a4bf5c1feb7e0c0b45d7c617669dc88756 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 3 May 2022 17:54:00 -0400 Subject: [PATCH 60/68] Always show answer panel --- web/pages/[username]/[contractSlug].tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 84231e5a..34ed776a 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -168,16 +168,15 @@ export function ContractPageContent(props: FirstArgument<typeof ContractPage>) { comments={comments ?? []} /> - {outcomeType === 'FREE_RESPONSE' && - (isResolved || resolution === 'MKT') && ( - <> - <Spacer h={4} /> - <AnswersPanel - contract={contract as FullContract<DPM, FreeResponse>} - /> - <Spacer h={4} /> - </> - )} + {outcomeType === 'FREE_RESPONSE' && ( + <> + <Spacer h={4} /> + <AnswersPanel + contract={contract as FullContract<DPM, FreeResponse>} + /> + <Spacer h={4} /> + </> + )} {isResolved && ( <> From 5c18820d9636d94b9d781ce45b6fa0a6beb9265b Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 4 May 2022 11:11:06 -0400 Subject: [PATCH 61/68] go back to $100 fixed ante --- functions/src/create-contract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index e531e1b3..dfc8128d 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -81,7 +81,7 @@ export const createContract = functions .get() const isFree = userContractsCreatedTodaySnapshot.size === 0 - const ante = isFree ? 300 : FIXED_ANTE // data.ante + const ante = FIXED_ANTE // data.ante if ( ante === undefined || From 95b67c05e2f306ddd05f1b2c35ecef913149e249 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 4 May 2022 11:47:45 -0400 Subject: [PATCH 62/68] another sell shares rounding bug --- web/components/amount-input.tsx | 5 +++-- web/components/bet-panel.tsx | 8 +++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index d4724c2a..76111b6f 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -178,9 +178,10 @@ export function SellAmountInput(props: { ] const sellOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined - const shares = yesShares || noShares + const shares = Math.round(yesShares) || Math.round(noShares) + + const sharesSold = Math.min(amount ?? 0, shares) - const sharesSold = Math.min(amount ?? 0, yesShares || noShares) const { saleValue } = calculateCpmmSale( contract, sharesSold, diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 677e0197..cff37929 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -432,7 +432,13 @@ export function SellPanel(props: { <SellAmountInput inputClassName="w-full" contract={contract} - amount={amount ? Math.floor(amount) : undefined} + amount={ + amount + ? Math.round(amount) === 0 + ? 0 + : Math.floor(amount) + : undefined + } onChange={setAmount} userBets={userBets} error={error} From a38b6d26cebf0e31d2ee56236b0bc91e6827ef25 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 4 May 2022 10:02:41 -0400 Subject: [PATCH 63/68] Remove number from chosen FR answer --- web/components/answers/answer-item.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/answers/answer-item.tsx b/web/components/answers/answer-item.tsx index fdeafea0..6abbcde0 100644 --- a/web/components/answers/answer-item.tsx +++ b/web/components/answers/answer-item.tsx @@ -68,7 +68,7 @@ export function AnswerItem(props: { </Row> </SiteLink> {/* TODO: Show total pool? */} - <div className="text-base">#{number}</div> + {/*<div className="text-base">#{number}</div>*/} </Row> </Col> From 0dc6c6405da6f49fa0ed68f477e0f4d500798ea8 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 4 May 2022 10:03:01 -0400 Subject: [PATCH 64/68] Distinguish wining and losing FR answers --- web/components/answers/answers-panel.tsx | 33 ++++++++++++++---------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 3af2c286..bde5cbcc 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -24,7 +24,7 @@ export function AnswersPanel(props: { const { creatorId, resolution, resolutions, totalBets } = contract const answers = useAnswers(contract.id) ?? contract.answers - const [winningAnswers, otherAnswers] = _.partition( + const [winningAnswers, losingAnswers] = _.partition( answers.filter( (answer) => answer.id !== '0' && totalBets[answer.id] > 0.000000001 ), @@ -36,7 +36,7 @@ export function AnswersPanel(props: { resolutions ? -1 * resolutions[answer.id] : 0 ), ..._.sortBy( - resolution ? [] : otherAnswers, + resolution ? [] : losingAnswers, (answer) => -1 * getDpmOutcomeProbability(contract.totalShares, answer.id) ), ] @@ -52,7 +52,11 @@ export function AnswersPanel(props: { const chosenTotal = _.sum(Object.values(chosenAnswers)) - const answerItems = getAnswers(contract, user) + const answerItems = getAnswers( + contract, + user, + winningAnswers.length > 0 ? winningAnswers.map((a) => a.id) : [] + ) const onChoose = (answerId: string, prob: number) => { if (resolveOption === 'CHOOSE') { @@ -89,9 +93,7 @@ export function AnswersPanel(props: { return ( <Col className="gap-3"> - {(resolveOption === 'CHOOSE' || - resolveOption === 'CHOOSE_MULTIPLE' || - resolution === 'MKT') && + {(resolveOption || resolution) && sortedAnswers.map((answer) => ( <AnswerItem key={answer.id} @@ -105,11 +107,7 @@ export function AnswersPanel(props: { /> ))} - {sortedAnswers.length === 0 && ( - <div className="pb-4 text-gray-500">No answers yet...</div> - )} - - {!resolveOption && sortedAnswers.length > 0 && ( + {!resolveOption && ( <FeedItems contract={contract} items={answerItems} @@ -118,6 +116,10 @@ export function AnswersPanel(props: { /> )} + {sortedAnswers.length === 0 && ( + <div className="pb-4 text-gray-500">No answers yet...</div> + )} + {tradingAllowed(contract) && (!resolveOption || resolveOption === 'CANCEL') && ( <CreateAnswerPanel contract={contract} /> @@ -140,13 +142,18 @@ export function AnswersPanel(props: { function getAnswers( contract: FullContract<DPM, FreeResponse>, - user: User | undefined | null + user: User | undefined | null, + ignoreAnswerOutcomes: string[] ) { const { answers } = contract let outcomes = _.uniq( answers.map((answer) => answer.number.toString()) - ).filter((outcome) => getOutcomeProbability(contract, outcome) > 0.0001) + ).filter( + (outcome) => + getOutcomeProbability(contract, outcome) > 0.0001 && + !ignoreAnswerOutcomes.includes(outcome) + ) outcomes = _.sortBy(outcomes, (outcome) => getOutcomeProbability(contract, outcome) ).reverse() From a155ff71eafb8188ea2796318a5a7a48751975d5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 4 May 2022 10:24:48 -0400 Subject: [PATCH 65/68] Show no answers text --- web/components/answers/answers-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index bde5cbcc..a4eab64d 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -116,7 +116,7 @@ export function AnswersPanel(props: { /> )} - {sortedAnswers.length === 0 && ( + {answers.length <= 1 && ( <div className="pb-4 text-gray-500">No answers yet...</div> )} From ff7c6dae0945507701102ce12cb8f6a29066089d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 4 May 2022 11:39:15 -0400 Subject: [PATCH 66/68] Simplify get answer items logic --- web/components/answers/answers-panel.tsx | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index a4eab64d..f315b514 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -52,10 +52,10 @@ export function AnswersPanel(props: { const chosenTotal = _.sum(Object.values(chosenAnswers)) - const answerItems = getAnswers( + const answerItems = getAnswerItems( contract, - user, - winningAnswers.length > 0 ? winningAnswers.map((a) => a.id) : [] + losingAnswers.length > 0 ? losingAnswers : sortedAnswers, + user ) const onChoose = (answerId: string, prob: number) => { @@ -140,20 +140,14 @@ export function AnswersPanel(props: { ) } -function getAnswers( +function getAnswerItems( contract: FullContract<DPM, FreeResponse>, - user: User | undefined | null, - ignoreAnswerOutcomes: string[] + answers: Answer[], + user: User | undefined | null ) { - const { answers } = contract - let outcomes = _.uniq( answers.map((answer) => answer.number.toString()) - ).filter( - (outcome) => - getOutcomeProbability(contract, outcome) > 0.0001 && - !ignoreAnswerOutcomes.includes(outcome) - ) + ).filter((outcome) => getOutcomeProbability(contract, outcome) > 0.0001) outcomes = _.sortBy(outcomes, (outcome) => getOutcomeProbability(contract, outcome) ).reverse() From 7274875c126982f80adc0770ba7c67900fabb9e1 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 4 May 2022 11:41:34 -0400 Subject: [PATCH 67/68] Show answer number --- web/components/answers/answer-item.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/answers/answer-item.tsx b/web/components/answers/answer-item.tsx index 6abbcde0..fdeafea0 100644 --- a/web/components/answers/answer-item.tsx +++ b/web/components/answers/answer-item.tsx @@ -68,7 +68,7 @@ export function AnswerItem(props: { </Row> </SiteLink> {/* TODO: Show total pool? */} - {/*<div className="text-base">#{number}</div>*/} + <div className="text-base">#{number}</div> </Row> </Col> From 9220c781bb063ca53307c25ad0bb12ff43c88b9f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 4 May 2022 12:01:40 -0400 Subject: [PATCH 68/68] Show answer # when resolving --- web/components/answers/answer-item.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/answers/answer-item.tsx b/web/components/answers/answer-item.tsx index fdeafea0..96746b62 100644 --- a/web/components/answers/answer-item.tsx +++ b/web/components/answers/answer-item.tsx @@ -68,7 +68,7 @@ export function AnswerItem(props: { </Row> </SiteLink> {/* TODO: Show total pool? */} - <div className="text-base">#{number}</div> + <div className="text-base">{showChoice && '#' + number}</div> </Row> </Col>