Merge branch 'main' into show-comments-on-profile
This commit is contained in:
commit
ab0fa2f522
27
.github/CONTRIBUTING.md
vendored
Normal file
27
.github/CONTRIBUTING.md
vendored
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
# Manifold CLA
|
||||||
|
|
||||||
|
**Manifold Markets Contributor License Agreement**
|
||||||
|
|
||||||
|
(Thanks to [Beeminder](http://bmndr.co/cla) and [Discourse.org](https://cla-assistant.io/discourse/discourse) whose CLAs 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.
|
|
@ -37,7 +37,9 @@ 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.
|
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.
|
||||||
|
|
||||||
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.
|
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][discord] as well.
|
||||||
|
|
||||||
[vercel]: https://vercel.com/
|
[vercel]: https://vercel.com/
|
||||||
[jamstack]: https://jamstack.org/
|
[jamstack]: https://jamstack.org/
|
||||||
|
@ -48,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-firestore]: https://firebase.google.com/docs/firestore
|
||||||
[cloud-functions]: https://firebase.google.com/docs/functions
|
[cloud-functions]: https://firebase.google.com/docs/functions
|
||||||
[small-prs]: https://google.github.io/eng-practices/review/developer/small-cls.html
|
[small-prs]: https://google.github.io/eng-practices/review/developer/small-cls.html
|
||||||
[discord]: https://discord.gg/eHQBNBqXuh
|
[discord]: https://discord.gg/3Zuth9792G
|
||||||
|
|
|
@ -5,14 +5,16 @@ import { User } from './user'
|
||||||
import { LiquidityProvision } from './liquidity-provision'
|
import { LiquidityProvision } from './liquidity-provision'
|
||||||
import { noFees } from './fees'
|
import { noFees } from './fees'
|
||||||
|
|
||||||
export const FIXED_ANTE = 50
|
export const FIXED_ANTE = 100
|
||||||
|
|
||||||
// deprecated
|
// deprecated
|
||||||
export const PHANTOM_ANTE = 0.001
|
export const PHANTOM_ANTE = 0.001
|
||||||
export const MINIMUM_ANTE = 50
|
export const MINIMUM_ANTE = 50
|
||||||
|
|
||||||
|
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
|
||||||
|
|
||||||
export function getCpmmInitialLiquidity(
|
export function getCpmmInitialLiquidity(
|
||||||
creator: User,
|
providerId: string,
|
||||||
contract: FullContract<CPMM, Binary>,
|
contract: FullContract<CPMM, Binary>,
|
||||||
anteId: string,
|
anteId: string,
|
||||||
amount: number
|
amount: number
|
||||||
|
@ -21,7 +23,7 @@ export function getCpmmInitialLiquidity(
|
||||||
|
|
||||||
const lp: LiquidityProvision = {
|
const lp: LiquidityProvision = {
|
||||||
id: anteId,
|
id: anteId,
|
||||||
userId: creator.id,
|
userId: providerId,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
createdTime,
|
createdTime,
|
||||||
isAnte: true,
|
isAnte: true,
|
||||||
|
|
|
@ -2,6 +2,8 @@ import * as _ from 'lodash'
|
||||||
|
|
||||||
import { Binary, CPMM, FullContract } from './contract'
|
import { Binary, CPMM, FullContract } from './contract'
|
||||||
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, noFees, PLATFORM_FEE } from './fees'
|
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, noFees, PLATFORM_FEE } from './fees'
|
||||||
|
import { LiquidityProvision } from './liquidity-provision'
|
||||||
|
import { addObjects } from './util/object'
|
||||||
|
|
||||||
export function getCpmmProbability(
|
export function getCpmmProbability(
|
||||||
pool: { [outcome: string]: number },
|
pool: { [outcome: string]: number },
|
||||||
|
@ -170,7 +172,7 @@ export function calculateCpmmSale(
|
||||||
shares: number,
|
shares: number,
|
||||||
outcome: string
|
outcome: string
|
||||||
) {
|
) {
|
||||||
if (shares < 0) {
|
if (Math.round(shares) < 0) {
|
||||||
throw new Error('Cannot sell non-positive shares')
|
throw new Error('Cannot sell non-positive shares')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -258,6 +260,36 @@ export function addCpmmLiquidity(
|
||||||
return { newPool, liquidity, newP }
|
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(
|
// export function removeCpmmLiquidity(
|
||||||
// contract: FullContract<CPMM, Binary>,
|
// contract: FullContract<CPMM, Binary>,
|
||||||
// liquidity: number
|
// liquidity: number
|
||||||
|
|
374
common/charity.ts
Normal file
374
common/charity.ts
Normal file
|
@ -0,0 +1,374 @@
|
||||||
|
export interface Charity {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
website: string
|
||||||
|
ein?: string
|
||||||
|
photo?: string
|
||||||
|
preview: string
|
||||||
|
description: string
|
||||||
|
tags?: CharityTag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
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://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.
|
||||||
|
|
||||||
|
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:
|
||||||
|
'The Quantified Uncertainty Research Institute advances forecasting and epistemics to improve the long-term future of humanity.',
|
||||||
|
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.
|
||||||
|
|
||||||
|
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://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).
|
||||||
|
|
||||||
|
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: 'GiveWell Maximum Impact Fund',
|
||||||
|
website: 'https://www.givewell.org/maximum-impact-fund',
|
||||||
|
photo: 'https://i.imgur.com/xikuDMZ.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/',
|
||||||
|
ein: '27-1661997',
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Hellen Keller International',
|
||||||
|
website: 'https://www.hki.org/',
|
||||||
|
ein: '13-5562162',
|
||||||
|
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:
|
||||||
|
'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://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.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Rethink Charity',
|
||||||
|
website: 'https://rethink.charity/',
|
||||||
|
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.
|
||||||
|
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/',
|
||||||
|
ein: '98-0627052',
|
||||||
|
photo: 'https://i.imgur.com/LGwy9d8.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: 'The Center for the Study of Partisanship and Ideology',
|
||||||
|
website: 'https://cspicenter.org/',
|
||||||
|
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?
|
||||||
|
|
||||||
|
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/',
|
||||||
|
ein: '01-0686889',
|
||||||
|
photo: 'https://i.imgur.com/3JXhuXl.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://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:
|
||||||
|
'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://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.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'New Incentives',
|
||||||
|
website: 'https://www.newincentives.org/',
|
||||||
|
ein: '45-2368993',
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SCI foundation',
|
||||||
|
website: 'https://schistosomiasiscontrolinitiative.org/',
|
||||||
|
ein: '',
|
||||||
|
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:
|
||||||
|
'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: '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.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Rainforest Trust',
|
||||||
|
website: 'https://www.rainforesttrust.org/',
|
||||||
|
ein: '13-3500609',
|
||||||
|
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:
|
||||||
|
'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://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.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Doctors Without Borders',
|
||||||
|
website: 'https://www.doctorswithoutborders.org/',
|
||||||
|
ein: '13-3433452',
|
||||||
|
photo: 'https://i.imgur.com/xqhH9FE.png',
|
||||||
|
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://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:
|
||||||
|
'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/',
|
||||||
|
photo: 'https://i.imgur.com/9cxuvZi.png',
|
||||||
|
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://i.imgur.com/Mf35IOu.jpg',
|
||||||
|
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://i.imgur.com/z87sFDE.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://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:
|
||||||
|
'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://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:
|
||||||
|
"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://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:
|
||||||
|
'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://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:
|
||||||
|
"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://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:
|
||||||
|
"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://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:
|
||||||
|
"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://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:
|
||||||
|
'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/',
|
||||||
|
photo: 'https://i.imgur.com/p235vwF.jpg',
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
|
@ -4,6 +4,7 @@ export type Comment = {
|
||||||
id: string
|
id: string
|
||||||
contractId: string
|
contractId: string
|
||||||
betId?: string
|
betId?: string
|
||||||
|
answerOutcome?: string
|
||||||
userId: string
|
userId: string
|
||||||
|
|
||||||
text: string
|
text: string
|
||||||
|
|
|
@ -20,7 +20,9 @@ export type FullContract<
|
||||||
visibility: 'public' | 'unlisted'
|
visibility: 'public' | 'unlisted'
|
||||||
|
|
||||||
createdTime: number // Milliseconds since epoch
|
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
|
closeTime?: number // When no more trading is allowed
|
||||||
|
|
||||||
isResolved: boolean
|
isResolved: boolean
|
||||||
|
|
|
@ -53,7 +53,6 @@ export function getNewContract(
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
isResolved: false,
|
isResolved: false,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
lastUpdatedTime: Date.now(),
|
|
||||||
closeTime,
|
closeTime,
|
||||||
|
|
||||||
volume,
|
volume,
|
||||||
|
|
|
@ -2,6 +2,7 @@ import * as _ from 'lodash'
|
||||||
|
|
||||||
import { Bet } from './bet'
|
import { Bet } from './bet'
|
||||||
import { getProbability } from './calculate'
|
import { getProbability } from './calculate'
|
||||||
|
import { getCpmmLiquidityPoolWeights } from './calculate-cpmm'
|
||||||
import { Binary, CPMM, FixedPayouts, FullContract } from './contract'
|
import { Binary, CPMM, FixedPayouts, FullContract } from './contract'
|
||||||
import { LiquidityProvision } from './liquidity-provision'
|
import { LiquidityProvision } from './liquidity-provision'
|
||||||
|
|
||||||
|
@ -60,14 +61,14 @@ export const getLiquidityPoolPayouts = (
|
||||||
outcome: string,
|
outcome: string,
|
||||||
liquidities: LiquidityProvision[]
|
liquidities: LiquidityProvision[]
|
||||||
) => {
|
) => {
|
||||||
const providedLiquidity = _.sumBy(liquidities, (lp) => lp.liquidity)
|
|
||||||
|
|
||||||
const { pool } = contract
|
const { pool } = contract
|
||||||
const finalPool = pool[outcome]
|
const finalPool = pool[outcome]
|
||||||
|
|
||||||
return liquidities.map((lp) => ({
|
const weights = getCpmmLiquidityPoolWeights(contract, liquidities)
|
||||||
userId: lp.userId,
|
|
||||||
payout: (lp.liquidity / providedLiquidity) * finalPool,
|
return Object.entries(weights).map(([providerId, weight]) => ({
|
||||||
|
userId: providerId,
|
||||||
|
payout: weight * finalPool,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,13 +112,13 @@ export const getLiquidityPoolProbPayouts = (
|
||||||
p: number,
|
p: number,
|
||||||
liquidities: LiquidityProvision[]
|
liquidities: LiquidityProvision[]
|
||||||
) => {
|
) => {
|
||||||
const providedLiquidity = _.sumBy(liquidities, (lp) => lp.liquidity)
|
|
||||||
|
|
||||||
const { pool } = contract
|
const { pool } = contract
|
||||||
const finalPool = p * pool.YES + (1 - p) * pool.NO
|
const finalPool = p * pool.YES + (1 - p) * pool.NO
|
||||||
|
|
||||||
return liquidities.map((lp) => ({
|
const weights = getCpmmLiquidityPoolWeights(contract, liquidities)
|
||||||
userId: lp.userId,
|
|
||||||
payout: (lp.liquidity / providedLiquidity) * finalPool,
|
return Object.entries(weights).map(([providerId, weight]) => ({
|
||||||
|
userId: providerId,
|
||||||
|
payout: weight * finalPool,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
import * as _ from 'lodash'
|
import * as _ from 'lodash'
|
||||||
|
import { Bet } from './bet'
|
||||||
import { Contract } from './contract'
|
import { Contract } from './contract'
|
||||||
|
import { ClickEvent } from './tracking'
|
||||||
import { filterDefined } from './util/array'
|
import { filterDefined } from './util/array'
|
||||||
import { addObjects } from './util/object'
|
import { addObjects } from './util/object'
|
||||||
|
|
||||||
|
export const MAX_FEED_CONTRACTS = 75
|
||||||
|
|
||||||
export const getRecommendedContracts = (
|
export const getRecommendedContracts = (
|
||||||
contractsById: { [contractId: string]: Contract },
|
contractsById: { [contractId: string]: Contract },
|
||||||
yourBetOnContractIds: string[]
|
yourBetOnContractIds: string[]
|
||||||
|
@ -92,3 +96,88 @@ const contractsToWordFrequency = (contracts: Contract[]) => {
|
||||||
|
|
||||||
return toFrequency(frequencySum)
|
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]]))
|
||||||
|
}
|
||||||
|
|
21
common/txn.ts
Normal file
21
common/txn.ts
Normal file
|
@ -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'
|
|
@ -1,5 +1,6 @@
|
||||||
rules_version = '2';
|
rules_version = '2';
|
||||||
|
|
||||||
|
// To pick the right project: `firebase projects:list`, then `firebase use <project-name>`
|
||||||
// To deploy: `firebase deploy --only firestore:rules`
|
// To deploy: `firebase deploy --only firestore:rules`
|
||||||
service cloud.firestore {
|
service cloud.firestore {
|
||||||
match /databases/{database}/documents {
|
match /databases/{database}/documents {
|
||||||
|
@ -34,6 +35,10 @@ service cloud.firestore {
|
||||||
allow create: if userId == request.auth.uid;
|
allow create: if userId == request.auth.uid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match /private-users/{userId}/cache/feed {
|
||||||
|
allow read: if userId == request.auth.uid || isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
match /contracts/{contractId} {
|
match /contracts/{contractId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
||||||
|
@ -76,5 +81,9 @@ service cloud.firestore {
|
||||||
allow create, update: if request.auth.uid == userId && request.resource.data.userId == userId;
|
allow create, update: if request.auth.uid == userId && request.resource.data.userId == userId;
|
||||||
allow delete: if request.auth.uid == userId;
|
allow delete: if request.auth.uid == userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match /txns/{txnId} {
|
||||||
|
allow read;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,6 +46,7 @@ export const backupDb = functions.pubsub
|
||||||
'comments',
|
'comments',
|
||||||
'followers',
|
'followers',
|
||||||
'answers',
|
'answers',
|
||||||
|
'txns',
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.then((responses) => {
|
.then((responses) => {
|
||||||
|
|
|
@ -22,6 +22,7 @@ import {
|
||||||
getAnteBets,
|
getAnteBets,
|
||||||
getCpmmInitialLiquidity,
|
getCpmmInitialLiquidity,
|
||||||
getFreeAnswerAnte,
|
getFreeAnswerAnte,
|
||||||
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
MINIMUM_ANTE,
|
MINIMUM_ANTE,
|
||||||
} from '../../common/antes'
|
} from '../../common/antes'
|
||||||
import { getNoneAnswer } from '../../common/answer'
|
import { getNoneAnswer } from '../../common/answer'
|
||||||
|
@ -71,7 +72,6 @@ export const createContract = functions
|
||||||
)
|
)
|
||||||
return { status: 'error', message: 'Invalid initial probability' }
|
return { status: 'error', message: 'Invalid initial probability' }
|
||||||
|
|
||||||
const ante = FIXED_ANTE // data.ante
|
|
||||||
// uses utc time on server:
|
// uses utc time on server:
|
||||||
const today = new Date().setHours(0, 0, 0, 0)
|
const today = new Date().setHours(0, 0, 0, 0)
|
||||||
const userContractsCreatedTodaySnapshot = await firestore
|
const userContractsCreatedTodaySnapshot = await firestore
|
||||||
|
@ -81,6 +81,8 @@ export const createContract = functions
|
||||||
.get()
|
.get()
|
||||||
const isFree = userContractsCreatedTodaySnapshot.size === 0
|
const isFree = userContractsCreatedTodaySnapshot.size === 0
|
||||||
|
|
||||||
|
const ante = FIXED_ANTE // data.ante
|
||||||
|
|
||||||
if (
|
if (
|
||||||
ante === undefined ||
|
ante === undefined ||
|
||||||
ante < MINIMUM_ANTE ||
|
ante < MINIMUM_ANTE ||
|
||||||
|
@ -144,8 +146,10 @@ export const createContract = functions
|
||||||
.collection(`contracts/${contract.id}/liquidity`)
|
.collection(`contracts/${contract.id}/liquidity`)
|
||||||
.doc()
|
.doc()
|
||||||
|
|
||||||
|
const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : creator.id
|
||||||
|
|
||||||
const lp = getCpmmInitialLiquidity(
|
const lp = getCpmmInitialLiquidity(
|
||||||
creator,
|
providerId,
|
||||||
contract as FullContract<CPMM, Binary>,
|
contract as FullContract<CPMM, Binary>,
|
||||||
liquidityDoc.id,
|
liquidityDoc.id,
|
||||||
ante
|
ante
|
||||||
|
|
|
@ -3,6 +3,7 @@ import * as admin from 'firebase-admin'
|
||||||
admin.initializeApp()
|
admin.initializeApp()
|
||||||
|
|
||||||
// export * from './keep-awake'
|
// export * from './keep-awake'
|
||||||
|
export * from './transact'
|
||||||
export * from './place-bet'
|
export * from './place-bet'
|
||||||
export * from './resolve-market'
|
export * from './resolve-market'
|
||||||
export * from './stripe'
|
export * from './stripe'
|
||||||
|
@ -12,12 +13,16 @@ export * from './create-contract'
|
||||||
export * from './create-user'
|
export * from './create-user'
|
||||||
export * from './create-fold'
|
export * from './create-fold'
|
||||||
export * from './create-answer'
|
export * from './create-answer'
|
||||||
|
export * from './on-create-bet'
|
||||||
export * from './on-create-comment'
|
export * from './on-create-comment'
|
||||||
export * from './on-fold-follow'
|
export * from './on-fold-follow'
|
||||||
export * from './on-fold-delete'
|
export * from './on-fold-delete'
|
||||||
|
export * from './on-view'
|
||||||
export * from './unsubscribe'
|
export * from './unsubscribe'
|
||||||
export * from './update-contract-metrics'
|
export * from './update-contract-metrics'
|
||||||
export * from './update-user-metrics'
|
export * from './update-user-metrics'
|
||||||
|
export * from './update-recommendations'
|
||||||
|
export * from './update-feed'
|
||||||
export * from './backup-db'
|
export * from './backup-db'
|
||||||
export * from './change-user-info'
|
export * from './change-user-info'
|
||||||
export * from './market-close-emails'
|
export * from './market-close-emails'
|
||||||
|
|
28
functions/src/on-create-bet.ts
Normal file
28
functions/src/on-create-bet.ts
Normal file
|
@ -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() })
|
||||||
|
})
|
|
@ -18,12 +18,19 @@ export const onCreateComment = functions.firestore
|
||||||
}
|
}
|
||||||
|
|
||||||
const contract = await getContract(contractId)
|
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 comment = change.data() as Comment
|
||||||
|
const lastCommentTime = comment.createdTime
|
||||||
|
|
||||||
const commentCreator = await getUser(comment.userId)
|
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 bet: Bet | undefined
|
||||||
let answer: Answer | undefined
|
let answer: Answer | undefined
|
||||||
|
|
24
functions/src/on-view.ts
Normal file
24
functions/src/on-view.ts
Normal file
|
@ -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 })
|
||||||
|
})
|
78
functions/src/scripts/cache-views.ts
Normal file
78
functions/src/scripts/cache-views.ts
Normal file
|
@ -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())
|
||||||
|
}
|
|
@ -106,7 +106,7 @@ async function recalculateContract(contractRef: DocRef, isCommit = false) {
|
||||||
const liquidityDocRef = contractRef.collection('liquidity').doc()
|
const liquidityDocRef = contractRef.collection('liquidity').doc()
|
||||||
|
|
||||||
const lp = getCpmmInitialLiquidity(
|
const lp = getCpmmInitialLiquidity(
|
||||||
{ id: 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' } as User, // use @ManifoldMarkets' id
|
'IPTOzEqrpkWmEzh6hwvAyY9PqFb2', // use @ManifoldMarkets' id
|
||||||
{
|
{
|
||||||
...contract,
|
...contract,
|
||||||
...contractUpdate,
|
...contractUpdate,
|
||||||
|
|
38
functions/src/scripts/update-feed.ts
Normal file
38
functions/src/scripts/update-feed.ts
Normal file
|
@ -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())
|
||||||
|
}
|
43
functions/src/scripts/update-last-comment-time.ts
Normal file
43
functions/src/scripts/update-last-comment-time.ts
Normal file
|
@ -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())
|
||||||
|
}
|
88
functions/src/transact.ts
Normal file
88
functions/src/transact.ts
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
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,
|
||||||
|
totalDeposits: toUser.totalDeposits + 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,
|
||||||
|
totalDeposits: fromUser.totalDeposits - amount,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { status: 'success', txn }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
210
functions/src/update-feed.ts
Normal file
210
functions/src/update-feed.ts
Normal file
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
71
functions/src/update-recommendations.ts
Normal file
71
functions/src/update-recommendations.ts
Normal file
|
@ -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)
|
||||||
|
}
|
|
@ -6,27 +6,33 @@ import { PrivateUser, User } from '../../common/user'
|
||||||
export const isProd =
|
export const isProd =
|
||||||
admin.instanceId().app.options.projectId === 'mantic-markets'
|
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()
|
const snap = await admin.firestore().collection(collection).doc(doc).get()
|
||||||
|
|
||||||
return snap.exists ? (snap.data() as T) : undefined
|
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) => {
|
export const getValues = async <T>(query: admin.firestore.Query) => {
|
||||||
const snap = await query.get()
|
const snap = await query.get()
|
||||||
return snap.docs.map((doc) => doc.data() as T)
|
return snap.docs.map((doc) => doc.data() as T)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getContract = (contractId: string) => {
|
export const getContract = (contractId: string) => {
|
||||||
return getValue<Contract>('contracts', contractId)
|
return getDoc<Contract>('contracts', contractId)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getUser = (userId: string) => {
|
export const getUser = (userId: string) => {
|
||||||
return getValue<User>('users', userId)
|
return getDoc<User>('users', userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getPrivateUser = (userId: string) => {
|
export const getPrivateUser = (userId: string) => {
|
||||||
return getValue<PrivateUser>('private-users', userId)
|
return getDoc<PrivateUser>('private-users', userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getUserByUsername = async (username: string) => {
|
export const getUserByUsername = async (username: string) => {
|
||||||
|
|
|
@ -85,7 +85,6 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
const {
|
const {
|
||||||
theme,
|
theme,
|
||||||
fontSize,
|
fontSize,
|
||||||
|
|
||||||
question,
|
question,
|
||||||
probability,
|
probability,
|
||||||
metadata,
|
metadata,
|
||||||
|
@ -93,6 +92,11 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
creatorUsername,
|
creatorUsername,
|
||||||
creatorAvatarUrl,
|
creatorAvatarUrl,
|
||||||
} = parsedReq
|
} = parsedReq
|
||||||
|
const MAX_QUESTION_CHARS = 100
|
||||||
|
const truncatedQuestion =
|
||||||
|
question.length > MAX_QUESTION_CHARS
|
||||||
|
? question.slice(0, MAX_QUESTION_CHARS) + '...'
|
||||||
|
: question
|
||||||
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
|
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
@ -141,7 +145,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
|
|
||||||
<div class="flex flex-row justify-between gap-12 pt-36">
|
<div class="flex flex-row justify-between gap-12 pt-36">
|
||||||
<div class="text-indigo-700 text-6xl leading-tight">
|
<div class="text-indigo-700 text-6xl leading-tight">
|
||||||
${question}
|
${truncatedQuestion}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col text-primary">
|
<div class="flex flex-col text-primary">
|
||||||
<div class="text-8xl">${probability}</div>
|
<div class="text-8xl">${probability}</div>
|
||||||
|
|
|
@ -6,8 +6,7 @@ export type OgCardProps = {
|
||||||
metadata: string
|
metadata: string
|
||||||
creatorName: string
|
creatorName: string
|
||||||
creatorUsername: string
|
creatorUsername: string
|
||||||
// TODO: Store creator avatar url in each contract, then enable this
|
creatorAvatarUrl?: string
|
||||||
// creatorAvatarUrl: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCardUrl(props: OgCardProps) {
|
function buildCardUrl(props: OgCardProps) {
|
||||||
|
@ -15,6 +14,10 @@ function buildCardUrl(props: OgCardProps) {
|
||||||
props.probability === undefined
|
props.probability === undefined
|
||||||
? ''
|
? ''
|
||||||
: `&probability=${encodeURIComponent(props.probability ?? '')}`
|
: `&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
|
// URL encode each of the props, then add them as query params
|
||||||
return (
|
return (
|
||||||
|
@ -23,6 +26,7 @@ function buildCardUrl(props: OgCardProps) {
|
||||||
probabilityParam +
|
probabilityParam +
|
||||||
`&metadata=${encodeURIComponent(props.metadata)}` +
|
`&metadata=${encodeURIComponent(props.metadata)}` +
|
||||||
`&creatorName=${encodeURIComponent(props.creatorName)}` +
|
`&creatorName=${encodeURIComponent(props.creatorName)}` +
|
||||||
|
creatorAvatarUrlParam +
|
||||||
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}`
|
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,7 +56,9 @@ export function AddLiquidityPanel(props: { contract: Contract }) {
|
||||||
|
|
||||||
return (
|
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>
|
<Row>
|
||||||
<AmountInput
|
<AmountInput
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { Bet } from '../../common/bet'
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
import { calculateCpmmSale } from '../../common/calculate-cpmm'
|
import { calculateCpmmSale } from '../../common/calculate-cpmm'
|
||||||
import { Binary, CPMM, FullContract } from '../../common/contract'
|
import { Binary, CPMM, FullContract } from '../../common/contract'
|
||||||
|
import { SiteLink } from './site-link'
|
||||||
|
|
||||||
export function AmountInput(props: {
|
export function AmountInput(props: {
|
||||||
amount: number | undefined
|
amount: number | undefined
|
||||||
|
@ -45,7 +46,7 @@ export function AmountInput(props: {
|
||||||
<span className="bg-gray-200 text-sm">{label}</span>
|
<span className="bg-gray-200 text-sm">{label}</span>
|
||||||
<input
|
<input
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'input input-bordered',
|
'input input-bordered max-w-[200px] text-lg',
|
||||||
error && 'input-error',
|
error && 'input-error',
|
||||||
inputClassName
|
inputClassName
|
||||||
)}
|
)}
|
||||||
|
@ -65,7 +66,16 @@ export function AmountInput(props: {
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -168,9 +178,10 @@ export function SellAmountInput(props: {
|
||||||
]
|
]
|
||||||
|
|
||||||
const sellOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined
|
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(
|
const { saleValue } = calculateCpmmSale(
|
||||||
contract,
|
contract,
|
||||||
sharesSold,
|
sharesSold,
|
||||||
|
|
|
@ -116,7 +116,7 @@ export function AnswerBetPanel(props: {
|
||||||
</Row>
|
</Row>
|
||||||
<div className="my-3 text-left text-sm text-gray-500">Amount </div>
|
<div className="my-3 text-left text-sm text-gray-500">Amount </div>
|
||||||
<BuyAmountInput
|
<BuyAmountInput
|
||||||
inputClassName="w-full"
|
inputClassName="w-full max-w-none"
|
||||||
amount={betAmount}
|
amount={betAmount}
|
||||||
onChange={setBetAmount}
|
onChange={setBetAmount}
|
||||||
error={error}
|
error={error}
|
||||||
|
|
|
@ -68,7 +68,7 @@ export function AnswerItem(props: {
|
||||||
</Row>
|
</Row>
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
{/* TODO: Show total pool? */}
|
{/* TODO: Show total pool? */}
|
||||||
<div className="text-base">#{number}</div>
|
<div className="text-base">{showChoice && '#' + number}</div>
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ export function AnswersPanel(props: {
|
||||||
const { creatorId, resolution, resolutions, totalBets } = contract
|
const { creatorId, resolution, resolutions, totalBets } = contract
|
||||||
|
|
||||||
const answers = useAnswers(contract.id) ?? contract.answers
|
const answers = useAnswers(contract.id) ?? contract.answers
|
||||||
const [winningAnswers, otherAnswers] = _.partition(
|
const [winningAnswers, losingAnswers] = _.partition(
|
||||||
answers.filter(
|
answers.filter(
|
||||||
(answer) => answer.id !== '0' && totalBets[answer.id] > 0.000000001
|
(answer) => answer.id !== '0' && totalBets[answer.id] > 0.000000001
|
||||||
),
|
),
|
||||||
|
@ -36,7 +36,7 @@ export function AnswersPanel(props: {
|
||||||
resolutions ? -1 * resolutions[answer.id] : 0
|
resolutions ? -1 * resolutions[answer.id] : 0
|
||||||
),
|
),
|
||||||
..._.sortBy(
|
..._.sortBy(
|
||||||
resolution ? [] : otherAnswers,
|
resolution ? [] : losingAnswers,
|
||||||
(answer) => -1 * getDpmOutcomeProbability(contract.totalShares, answer.id)
|
(answer) => -1 * getDpmOutcomeProbability(contract.totalShares, answer.id)
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -52,7 +52,11 @@ export function AnswersPanel(props: {
|
||||||
|
|
||||||
const chosenTotal = _.sum(Object.values(chosenAnswers))
|
const chosenTotal = _.sum(Object.values(chosenAnswers))
|
||||||
|
|
||||||
const answerItems = getAnswers(contract, user)
|
const answerItems = getAnswerItems(
|
||||||
|
contract,
|
||||||
|
losingAnswers.length > 0 ? losingAnswers : sortedAnswers,
|
||||||
|
user
|
||||||
|
)
|
||||||
|
|
||||||
const onChoose = (answerId: string, prob: number) => {
|
const onChoose = (answerId: string, prob: number) => {
|
||||||
if (resolveOption === 'CHOOSE') {
|
if (resolveOption === 'CHOOSE') {
|
||||||
|
@ -89,9 +93,7 @@ export function AnswersPanel(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className="gap-3">
|
<Col className="gap-3">
|
||||||
{(resolveOption === 'CHOOSE' ||
|
{(resolveOption || resolution) &&
|
||||||
resolveOption === 'CHOOSE_MULTIPLE' ||
|
|
||||||
resolution === 'MKT') &&
|
|
||||||
sortedAnswers.map((answer) => (
|
sortedAnswers.map((answer) => (
|
||||||
<AnswerItem
|
<AnswerItem
|
||||||
key={answer.id}
|
key={answer.id}
|
||||||
|
@ -105,11 +107,7 @@ export function AnswersPanel(props: {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{sortedAnswers.length === 0 && (
|
{!resolveOption && (
|
||||||
<div className="pb-4 text-gray-500">No answers yet...</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!resolveOption && sortedAnswers.length > 0 && (
|
|
||||||
<FeedItems
|
<FeedItems
|
||||||
contract={contract}
|
contract={contract}
|
||||||
items={answerItems}
|
items={answerItems}
|
||||||
|
@ -118,6 +116,10 @@ export function AnswersPanel(props: {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{answers.length <= 1 && (
|
||||||
|
<div className="pb-4 text-gray-500">No answers yet...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tradingAllowed(contract) &&
|
{tradingAllowed(contract) &&
|
||||||
(!resolveOption || resolveOption === 'CANCEL') && (
|
(!resolveOption || resolveOption === 'CANCEL') && (
|
||||||
<CreateAnswerPanel contract={contract} />
|
<CreateAnswerPanel contract={contract} />
|
||||||
|
@ -138,12 +140,11 @@ export function AnswersPanel(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAnswers(
|
function getAnswerItems(
|
||||||
contract: FullContract<DPM, FreeResponse>,
|
contract: FullContract<DPM, FreeResponse>,
|
||||||
|
answers: Answer[],
|
||||||
user: User | undefined | null
|
user: User | undefined | null
|
||||||
) {
|
) {
|
||||||
const { answers } = contract
|
|
||||||
|
|
||||||
let outcomes = _.uniq(
|
let outcomes = _.uniq(
|
||||||
answers.map((answer) => answer.number.toString())
|
answers.map((answer) => answer.number.toString())
|
||||||
).filter((outcome) => getOutcomeProbability(contract, outcome) > 0.0001)
|
).filter((outcome) => getOutcomeProbability(contract, outcome) > 0.0001)
|
||||||
|
|
|
@ -62,7 +62,7 @@ export function BetPanel(props: {
|
||||||
className
|
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" /> */}
|
{/* <Title className={clsx('!mt-0 text-neutral')} text="Place a trade" /> */}
|
||||||
|
|
||||||
<BuyPanel contract={contract} user={user} userBets={userBets ?? []} />
|
<BuyPanel contract={contract} user={user} userBets={userBets ?? []} />
|
||||||
|
@ -125,6 +125,7 @@ export function BetPanelSwitcher(props: {
|
||||||
<BinaryOutcomeLabel outcome={sharesOutcome} /> shares
|
<BinaryOutcomeLabel outcome={sharesOutcome} /> shares
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tradeType === 'BUY' && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
style={{
|
style={{
|
||||||
|
@ -133,11 +134,14 @@ export function BetPanelSwitcher(props: {
|
||||||
color: '#3D4451',
|
color: '#3D4451',
|
||||||
}}
|
}}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
tradeType === 'BUY' ? setTradeType('SELL') : setTradeType('BUY')
|
tradeType === 'BUY'
|
||||||
|
? setTradeType('SELL')
|
||||||
|
: setTradeType('BUY')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{tradeType === 'BUY' ? 'Sell' : 'Bet'}
|
{tradeType === 'BUY' ? 'Sell' : 'Bet'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
|
@ -299,7 +303,7 @@ function BuyPanel(props: {
|
||||||
/>
|
/>
|
||||||
<div className="my-3 text-left text-sm text-gray-500">Amount</div>
|
<div className="my-3 text-left text-sm text-gray-500">Amount</div>
|
||||||
<BuyAmountInput
|
<BuyAmountInput
|
||||||
inputClassName="w-full"
|
inputClassName="w-full max-w-none"
|
||||||
amount={betAmount}
|
amount={betAmount}
|
||||||
onChange={onBetChange}
|
onChange={onBetChange}
|
||||||
error={error}
|
error={error}
|
||||||
|
@ -428,7 +432,13 @@ export function SellPanel(props: {
|
||||||
<SellAmountInput
|
<SellAmountInput
|
||||||
inputClassName="w-full"
|
inputClassName="w-full"
|
||||||
contract={contract}
|
contract={contract}
|
||||||
amount={amount ? Math.floor(amount) : undefined}
|
amount={
|
||||||
|
amount
|
||||||
|
? Math.round(amount) === 0
|
||||||
|
? 0
|
||||||
|
: Math.floor(amount)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onChange={setAmount}
|
onChange={setAmount}
|
||||||
userBets={userBets}
|
userBets={userBets}
|
||||||
error={error}
|
error={error}
|
||||||
|
|
56
web/components/charity/charity-card.tsx
Normal file
56
web/components/charity/charity-card.tsx
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
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]'
|
||||||
|
import { Row } from '../layout/row'
|
||||||
|
|
||||||
|
export function CharityCard(props: { charity: Charity }) {
|
||||||
|
const { name, slug, photo, preview, id, tags } = 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 flex-1 cursor-pointer border-2 bg-white hover:shadow-md">
|
||||||
|
<Row className="mt-6">
|
||||||
|
{tags?.includes('Featured') && <FeaturedBadge />}
|
||||||
|
</Row>
|
||||||
|
<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>
|
||||||
|
{raised > 0 && (
|
||||||
|
<Row className="text-primary mt-4 flex-1 items-end justify-center gap-2">
|
||||||
|
<span className="text-3xl">
|
||||||
|
{raised < 100
|
||||||
|
? manaToUSD(raised)
|
||||||
|
: '$' + Math.floor(raised / 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>
|
||||||
|
)
|
||||||
|
}
|
34
web/components/charity/feed-items.tsx
Normal file
34
web/components/charity/feed-items.tsx
Normal file
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -49,7 +49,19 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
<Col className="gap-4 rounded bg-white p-6">
|
<Col className="gap-4 rounded bg-white p-6">
|
||||||
<Title className="!mt-0 !mb-0" text="Market info" />
|
<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">
|
<table className="table-compact table-zebra table w-full text-gray-500">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -97,19 +109,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div className="text-gray-500">Share</div>
|
<div>Tags</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>
|
|
||||||
<TagsInput contract={contract} />
|
<TagsInput contract={contract} />
|
||||||
<div />
|
<div />
|
||||||
|
|
||||||
|
@ -117,7 +117,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
!contract.resolution &&
|
!contract.resolution &&
|
||||||
(!closeTime || closeTime > Date.now()) && (
|
(!closeTime || closeTime > Date.now()) && (
|
||||||
<>
|
<>
|
||||||
<div className="text-gray-500">Add liquidity</div>
|
<div className="">Add liquidity</div>
|
||||||
<AddLiquidityPanel contract={contract} />
|
<AddLiquidityPanel contract={contract} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { AnswersGraph } from '../answers/answers-graph'
|
||||||
import { DPM, FreeResponse, FullContract } from '../../../common/contract'
|
import { DPM, FreeResponse, FullContract } from '../../../common/contract'
|
||||||
import { ContractDescription } from './contract-description'
|
import { ContractDescription } from './contract-description'
|
||||||
import { ContractDetails } from './contract-details'
|
import { ContractDetails } from './contract-details'
|
||||||
|
import { ShareMarket } from '../share-market'
|
||||||
|
|
||||||
export const ContractOverview = (props: {
|
export const ContractOverview = (props: {
|
||||||
contract: Contract
|
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
|
<ContractDescription
|
||||||
className="px-2"
|
className="px-2"
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { ContractActivity } from '../feed/contract-activity'
|
||||||
import { ContractBetsTable, MyBetsSummary } from '../bets-list'
|
import { ContractBetsTable, MyBetsSummary } from '../bets-list'
|
||||||
import { Spacer } from '../layout/spacer'
|
import { Spacer } from '../layout/spacer'
|
||||||
import { Tabs } from '../layout/tabs'
|
import { Tabs } from '../layout/tabs'
|
||||||
|
import { Col } from '../layout/col'
|
||||||
|
|
||||||
export function ContractTabs(props: {
|
export function ContractTabs(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -33,14 +34,34 @@ export function ContractTabs(props: {
|
||||||
)
|
)
|
||||||
|
|
||||||
const commentActivity = (
|
const commentActivity = (
|
||||||
|
<>
|
||||||
<ContractActivity
|
<ContractActivity
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bets={bets}
|
bets={bets}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
user={user}
|
user={user}
|
||||||
mode="comments"
|
mode={
|
||||||
|
contract.outcomeType === 'FREE_RESPONSE'
|
||||||
|
? 'free-response-comment-answer-groups'
|
||||||
|
: 'comments'
|
||||||
|
}
|
||||||
betRowClassName="!mt-0 xl:hidden"
|
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 = (
|
const yourTrades = (
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
useQueryAndSortParams,
|
useQueryAndSortParams,
|
||||||
} from '../../hooks/use-sort-and-query-params'
|
} from '../../hooks/use-sort-and-query-params'
|
||||||
import { Answer } from '../../../common/answer'
|
import { Answer } from '../../../common/answer'
|
||||||
|
import { LoadingIndicator } from '../loading-indicator'
|
||||||
|
|
||||||
export function ContractsGrid(props: {
|
export function ContractsGrid(props: {
|
||||||
contracts: Contract[]
|
contracts: Contract[]
|
||||||
|
@ -213,7 +214,7 @@ function TagContractsGrid(props: { contracts: Contract[] }) {
|
||||||
const MAX_CONTRACTS_DISPLAYED = 99
|
const MAX_CONTRACTS_DISPLAYED = 99
|
||||||
|
|
||||||
export function SearchableGrid(props: {
|
export function SearchableGrid(props: {
|
||||||
contracts: Contract[]
|
contracts: Contract[] | undefined
|
||||||
byOneCreator?: boolean
|
byOneCreator?: boolean
|
||||||
querySortOptions?: {
|
querySortOptions?: {
|
||||||
defaultSort: Sort
|
defaultSort: Sort
|
||||||
|
@ -230,7 +231,7 @@ export function SearchableGrid(props: {
|
||||||
return queryWords.every((word) => corpus.toLowerCase().includes(word))
|
return queryWords.every((word) => corpus.toLowerCase().includes(word))
|
||||||
}
|
}
|
||||||
|
|
||||||
let matches = contracts.filter(
|
let matches = (contracts ?? []).filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
check(c.question) ||
|
check(c.question) ||
|
||||||
check(c.description) ||
|
check(c.description) ||
|
||||||
|
@ -324,7 +325,9 @@ export function SearchableGrid(props: {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sort === 'tag' ? (
|
{contracts === undefined ? (
|
||||||
|
<LoadingIndicator />
|
||||||
|
) : sort === 'tag' ? (
|
||||||
<TagContractsGrid contracts={matches} />
|
<TagContractsGrid contracts={matches} />
|
||||||
) : !byOneCreator && sort === 'creator' ? (
|
) : !byOneCreator && sort === 'creator' ? (
|
||||||
<CreatorContractsGrid contracts={matches} />
|
<CreatorContractsGrid contracts={matches} />
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Fragment } from 'react'
|
import { Fragment } from 'react'
|
||||||
import { LinkIcon } from '@heroicons/react/outline'
|
import { LinkIcon } from '@heroicons/react/outline'
|
||||||
import { Menu, Transition } from '@headlessui/react'
|
import { Menu, Transition } from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { copyToClipboard } from '../lib/util/copy'
|
import { copyToClipboard } from '../lib/util/copy'
|
||||||
import { contractPath } from '../lib/firebase/contracts'
|
import { contractPath } from '../lib/firebase/contracts'
|
||||||
|
@ -10,8 +11,11 @@ function copyContractUrl(contract: Contract) {
|
||||||
copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`)
|
copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CopyLinkButton(props: { contract: Contract }) {
|
export function CopyLinkButton(props: {
|
||||||
const { contract } = props
|
contract: Contract
|
||||||
|
buttonClassName?: string
|
||||||
|
}) {
|
||||||
|
const { contract, buttonClassName } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
|
@ -20,12 +24,10 @@ export function CopyLinkButton(props: { contract: Contract }) {
|
||||||
onMouseUp={() => copyContractUrl(contract)}
|
onMouseUp={() => copyContractUrl(contract)}
|
||||||
>
|
>
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
className="btn btn-xs normal-case"
|
className={clsx(
|
||||||
style={{
|
'btn btn-xs border-2 border-green-600 bg-white normal-case text-green-600 hover:border-green-600 hover:bg-white',
|
||||||
backgroundColor: 'white',
|
buttonClassName
|
||||||
border: '2px solid #16A34A',
|
)}
|
||||||
color: '#16A34A', // text-green-600
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
<LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||||
Copy link
|
Copy link
|
||||||
|
|
|
@ -8,31 +8,27 @@ import { useUser } from '../../hooks/use-user'
|
||||||
import { ContractActivity } from './contract-activity'
|
import { ContractActivity } from './contract-activity'
|
||||||
|
|
||||||
export function ActivityFeed(props: {
|
export function ActivityFeed(props: {
|
||||||
contracts: Contract[]
|
feed: {
|
||||||
|
contract: Contract
|
||||||
recentBets: Bet[]
|
recentBets: Bet[]
|
||||||
recentComments: Comment[]
|
recentComments: Comment[]
|
||||||
|
}[]
|
||||||
mode: 'only-recent' | 'abbreviated' | 'all'
|
mode: 'only-recent' | 'abbreviated' | 'all'
|
||||||
getContractPath?: (contract: Contract) => string
|
getContractPath?: (contract: Contract) => string
|
||||||
}) {
|
}) {
|
||||||
const { contracts, recentBets, recentComments, mode, getContractPath } = props
|
const { feed, mode, getContractPath } = props
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
|
||||||
const groupedBets = _.groupBy(recentBets, (bet) => bet.contractId)
|
|
||||||
const groupedComments = _.groupBy(
|
|
||||||
recentComments,
|
|
||||||
(comment) => comment.contractId
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeedContainer
|
<FeedContainer
|
||||||
contracts={contracts}
|
feed={feed}
|
||||||
renderContract={(contract) => (
|
renderItem={({ contract, recentBets, recentComments }) => (
|
||||||
<ContractActivity
|
<ContractActivity
|
||||||
user={user}
|
user={user}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bets={groupedBets[contract.id] ?? []}
|
bets={recentBets}
|
||||||
comments={groupedComments[contract.id] ?? []}
|
comments={recentComments}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
contractPath={getContractPath ? getContractPath(contract) : undefined}
|
contractPath={getContractPath ? getContractPath(contract) : undefined}
|
||||||
/>
|
/>
|
||||||
|
@ -42,18 +38,26 @@ export function ActivityFeed(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedContainer(props: {
|
function FeedContainer(props: {
|
||||||
contracts: Contract[]
|
feed: {
|
||||||
renderContract: (contract: Contract) => any
|
contract: Contract
|
||||||
|
recentBets: Bet[]
|
||||||
|
recentComments: Comment[]
|
||||||
|
}[]
|
||||||
|
renderItem: (item: {
|
||||||
|
contract: Contract
|
||||||
|
recentBets: Bet[]
|
||||||
|
recentComments: Comment[]
|
||||||
|
}) => any
|
||||||
}) {
|
}) {
|
||||||
const { contracts, renderContract } = props
|
const { feed, renderItem } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className="items-center">
|
<Col className="items-center">
|
||||||
<Col className="w-full max-w-3xl">
|
<Col className="w-full max-w-3xl">
|
||||||
<Col className="w-full divide-y divide-gray-300 self-center bg-white">
|
<Col className="w-full divide-y divide-gray-300 self-center bg-white">
|
||||||
{contracts.map((contract) => (
|
{feed.map((item) => (
|
||||||
<div key={contract.id} className="py-6 px-2 sm:px-4">
|
<div key={item.contract.id} className="py-6 px-2 sm:px-4">
|
||||||
{renderContract(contract)}
|
{renderItem(item)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -31,6 +31,9 @@ type BaseActivityItem = {
|
||||||
|
|
||||||
export type CommentInputItem = BaseActivityItem & {
|
export type CommentInputItem = BaseActivityItem & {
|
||||||
type: 'commentInput'
|
type: 'commentInput'
|
||||||
|
betsByCurrentUser: Bet[]
|
||||||
|
comments: Comment[]
|
||||||
|
answerOutcome?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DescriptionItem = BaseActivityItem & {
|
export type DescriptionItem = BaseActivityItem & {
|
||||||
|
@ -48,12 +51,13 @@ export type BetItem = BaseActivityItem & {
|
||||||
bet: Bet
|
bet: Bet
|
||||||
hideOutcome: boolean
|
hideOutcome: boolean
|
||||||
smallAvatar: boolean
|
smallAvatar: boolean
|
||||||
|
hideComment?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CommentItem = BaseActivityItem & {
|
export type CommentItem = BaseActivityItem & {
|
||||||
type: 'comment'
|
type: 'comment'
|
||||||
comment: Comment
|
comment: Comment
|
||||||
bet: Bet | undefined
|
betsBySameUser: Bet[]
|
||||||
hideOutcome: boolean
|
hideOutcome: boolean
|
||||||
truncate: boolean
|
truncate: boolean
|
||||||
smallAvatar: boolean
|
smallAvatar: boolean
|
||||||
|
@ -79,6 +83,7 @@ export type ResolveItem = BaseActivityItem & {
|
||||||
type: 'resolve'
|
type: 'resolve'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const GENERAL_COMMENTS_OUTCOME_ID = 'General Comments'
|
||||||
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||||
const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3
|
const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3
|
||||||
|
|
||||||
|
@ -129,7 +134,7 @@ function groupBets(
|
||||||
type: 'comment' as const,
|
type: 'comment' as const,
|
||||||
id: bet.id,
|
id: bet.id,
|
||||||
comment,
|
comment,
|
||||||
bet,
|
betsBySameUser: [bet],
|
||||||
contract,
|
contract,
|
||||||
hideOutcome,
|
hideOutcome,
|
||||||
truncate: abbreviated,
|
truncate: abbreviated,
|
||||||
|
@ -260,6 +265,68 @@ function getAnswerGroups(
|
||||||
return answerGroups
|
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(
|
function groupBetsAndComments(
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
comments: Comment[],
|
comments: Comment[],
|
||||||
|
@ -280,7 +347,7 @@ function groupBetsAndComments(
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
contract: contract,
|
contract: contract,
|
||||||
comment,
|
comment,
|
||||||
bet: undefined,
|
betsBySameUser: [],
|
||||||
truncate: abbreviated,
|
truncate: abbreviated,
|
||||||
hideOutcome: true,
|
hideOutcome: true,
|
||||||
smallAvatar,
|
smallAvatar,
|
||||||
|
@ -308,6 +375,27 @@ function groupBetsAndComments(
|
||||||
return abbrItems
|
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: false,
|
||||||
|
hideOutcome: false,
|
||||||
|
smallAvatar: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
export function getAllContractActivityItems(
|
export function getAllContractActivityItems(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
|
@ -358,9 +446,11 @@ export function getAllContractActivityItems(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
items.push({
|
items.push({
|
||||||
type: 'commentInput',
|
type: 'commentInput' as const,
|
||||||
id: 'commentInput',
|
id: 'commentInput',
|
||||||
contract,
|
contract,
|
||||||
|
betsByCurrentUser: [],
|
||||||
|
comments: [],
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
items.push(
|
items.push(
|
||||||
|
@ -382,9 +472,11 @@ export function getAllContractActivityItems(
|
||||||
|
|
||||||
if (outcomeType === 'BINARY') {
|
if (outcomeType === 'BINARY') {
|
||||||
items.push({
|
items.push({
|
||||||
type: 'commentInput',
|
type: 'commentInput' as const,
|
||||||
id: 'commentInput',
|
id: 'commentInput',
|
||||||
contract,
|
contract,
|
||||||
|
betsByCurrentUser: [],
|
||||||
|
comments: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -432,24 +524,13 @@ export function getRecentContractActivityItems(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
const onlyUsersBetsOrBetsWithComments = bets.filter((bet) =>
|
|
||||||
comments.some(
|
|
||||||
(comment) => comment.betId === bet.id || bet.userId === user?.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
items.push(
|
items.push(
|
||||||
...groupBetsAndComments(
|
...groupBetsAndComments(bets, comments, contract, user?.id, {
|
||||||
onlyUsersBetsOrBetsWithComments,
|
|
||||||
comments,
|
|
||||||
contract,
|
|
||||||
user?.id,
|
|
||||||
{
|
|
||||||
hideOutcome: false,
|
hideOutcome: false,
|
||||||
abbreviated: true,
|
abbreviated: true,
|
||||||
smallAvatar: false,
|
smallAvatar: false,
|
||||||
reversed: true,
|
reversed: true,
|
||||||
}
|
})
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -462,7 +543,7 @@ export function getSpecificContractActivityItems(
|
||||||
comments: Comment[],
|
comments: Comment[],
|
||||||
user: User | null | undefined,
|
user: User | null | undefined,
|
||||||
options: {
|
options: {
|
||||||
mode: 'comments' | 'bets'
|
mode: 'comments' | 'bets' | 'free-response-comment-answer-groups'
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const { mode } = options
|
const { mode } = options
|
||||||
|
@ -471,39 +552,52 @@ export function getSpecificContractActivityItems(
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'bets':
|
case 'bets':
|
||||||
items.push(
|
items.push(
|
||||||
...groupBets(bets, comments, contract, user?.id, {
|
...bets.map((bet) => ({
|
||||||
|
type: 'bet' as const,
|
||||||
|
id: bet.id,
|
||||||
|
bet,
|
||||||
|
contract,
|
||||||
hideOutcome: false,
|
hideOutcome: false,
|
||||||
abbreviated: false,
|
|
||||||
smallAvatar: false,
|
smallAvatar: false,
|
||||||
reversed: false,
|
hideComment: true,
|
||||||
})
|
}))
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'comments':
|
case 'comments':
|
||||||
const onlyBetsWithComments = bets.filter((bet) =>
|
const nonFreeResponseComments = comments.filter(
|
||||||
comments.some((comment) => comment.betId === bet.id)
|
(comment) => comment.answerOutcome === undefined
|
||||||
)
|
)
|
||||||
|
const nonFreeResponseBets =
|
||||||
|
contract.outcomeType === 'FREE_RESPONSE' ? [] : bets
|
||||||
items.push(
|
items.push(
|
||||||
...groupBetsAndComments(
|
...getCommentsWithPositions(
|
||||||
onlyBetsWithComments,
|
nonFreeResponseBets,
|
||||||
comments,
|
nonFreeResponseComments,
|
||||||
contract,
|
contract
|
||||||
user?.id,
|
|
||||||
{
|
|
||||||
hideOutcome: false,
|
|
||||||
abbreviated: false,
|
|
||||||
smallAvatar: false,
|
|
||||||
reversed: false,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
type: 'commentInput',
|
type: 'commentInput',
|
||||||
id: 'commentInput',
|
id: 'commentInput',
|
||||||
contract,
|
contract,
|
||||||
|
betsByCurrentUser: user
|
||||||
|
? nonFreeResponseBets.filter((bet) => bet.userId === user.id)
|
||||||
|
: [],
|
||||||
|
comments: nonFreeResponseComments,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
case 'free-response-comment-answer-groups':
|
||||||
|
items.push(
|
||||||
|
...getAnswerAndCommentInputGroups(
|
||||||
|
contract as FullContract<DPM, FreeResponse>,
|
||||||
|
bets,
|
||||||
|
comments,
|
||||||
|
user
|
||||||
|
)
|
||||||
|
)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return items.reverse()
|
return items.reverse()
|
||||||
|
|
|
@ -16,7 +16,13 @@ export function ContractActivity(props: {
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
user: User | null | undefined
|
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
|
contractPath?: string
|
||||||
className?: string
|
className?: string
|
||||||
betRowClassName?: string
|
betRowClassName?: string
|
||||||
|
@ -38,7 +44,9 @@ export function ContractActivity(props: {
|
||||||
? getRecentContractActivityItems(contract, bets, comments, user, {
|
? getRecentContractActivityItems(contract, bets, comments, user, {
|
||||||
contractPath,
|
contractPath,
|
||||||
})
|
})
|
||||||
: mode === 'comments' || mode === 'bets'
|
: mode === 'comments' ||
|
||||||
|
mode === 'bets' ||
|
||||||
|
mode === 'free-response-comment-answer-groups'
|
||||||
? getSpecificContractActivityItems(contract, bets, comments, user, {
|
? getSpecificContractActivityItems(contract, bets, comments, user, {
|
||||||
mode,
|
mode,
|
||||||
})
|
})
|
||||||
|
|
|
@ -9,7 +9,6 @@ import {
|
||||||
UserIcon,
|
UserIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
SparklesIcon,
|
|
||||||
} from '@heroicons/react/solid'
|
} from '@heroicons/react/solid'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Textarea from 'react-expanding-textarea'
|
import Textarea from 'react-expanding-textarea'
|
||||||
|
@ -38,8 +37,14 @@ import { fromNow } from '../../lib/util/time'
|
||||||
import BetRow from '../bet-row'
|
import BetRow from '../bet-row'
|
||||||
import { Avatar } from '../avatar'
|
import { Avatar } from '../avatar'
|
||||||
import { Answer } from '../../../common/answer'
|
import { Answer } from '../../../common/answer'
|
||||||
import { ActivityItem } from './activity-items'
|
import { ActivityItem, GENERAL_COMMENTS_OUTCOME_ID } 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 { BuyButton } from '../yes-no-selector'
|
||||||
import { getDpmOutcomeProbability } from '../../../common/calculate-dpm'
|
import { getDpmOutcomeProbability } from '../../../common/calculate-dpm'
|
||||||
import { AnswerBetPanel } from '../answers/answer-bet-panel'
|
import { AnswerBetPanel } from '../answers/answer-bet-panel'
|
||||||
|
@ -51,6 +56,7 @@ import { firebaseLogin } from '../../lib/firebase/users'
|
||||||
import { DAY_MS } from '../../../common/util/time'
|
import { DAY_MS } from '../../../common/util/time'
|
||||||
import NewContractBadge from '../new-contract-badge'
|
import NewContractBadge from '../new-contract-badge'
|
||||||
import { RelativeTimestamp } from '../relative-timestamp'
|
import { RelativeTimestamp } from '../relative-timestamp'
|
||||||
|
import { calculateCpmmSale } from '../../../common/calculate-cpmm'
|
||||||
|
|
||||||
export function FeedItems(props: {
|
export function FeedItems(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -124,21 +130,38 @@ function FeedItem(props: { item: ActivityItem }) {
|
||||||
export function FeedComment(props: {
|
export function FeedComment(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
comment: Comment
|
comment: Comment
|
||||||
bet: Bet | undefined
|
betsBySameUser: Bet[]
|
||||||
hideOutcome: boolean
|
hideOutcome: boolean
|
||||||
truncate: boolean
|
truncate: boolean
|
||||||
smallAvatar: boolean
|
smallAvatar: boolean
|
||||||
}) {
|
}) {
|
||||||
const { contract, comment, bet, hideOutcome, truncate, smallAvatar } = props
|
const {
|
||||||
let money: string | undefined
|
contract,
|
||||||
let outcome: string | undefined
|
comment,
|
||||||
let bought: string | undefined
|
betsBySameUser,
|
||||||
if (bet) {
|
hideOutcome,
|
||||||
outcome = bet.outcome
|
truncate,
|
||||||
bought = bet.amount >= 0 ? 'bought' : 'sold'
|
smallAvatar,
|
||||||
money = formatMoney(Math.abs(bet.amount))
|
} = props
|
||||||
}
|
|
||||||
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -156,8 +179,22 @@ export function FeedComment(props: {
|
||||||
username={userUsername}
|
username={userUsername}
|
||||||
name={userName}
|
name={userName}
|
||||||
/>{' '}
|
/>{' '}
|
||||||
|
{!matchedBet && userPosition > 0 && (
|
||||||
|
<>
|
||||||
|
{'had ' + userPositionMoney + ' '}
|
||||||
|
<>
|
||||||
|
{' of '}
|
||||||
|
<OutcomeLabel
|
||||||
|
outcome={yesFloorShares > noFloorShares ? 'YES' : 'NO'}
|
||||||
|
contract={contract}
|
||||||
|
truncate="short"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<>
|
||||||
{bought} {money}
|
{bought} {money}
|
||||||
{!hideOutcome && (
|
{outcome && !hideOutcome && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
of{' '}
|
of{' '}
|
||||||
|
@ -168,6 +205,7 @@ export function FeedComment(props: {
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
<RelativeTimestamp time={createdTime} />
|
<RelativeTimestamp time={createdTime} />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -181,59 +219,191 @@ export function FeedComment(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommentInput(props: { contract: Contract }) {
|
export function CommentInput(props: {
|
||||||
// see if we can comment input on any bet:
|
contract: Contract
|
||||||
const { contract } = props
|
betsByCurrentUser: Bet[]
|
||||||
|
comments: Comment[]
|
||||||
|
// Only for free response comment inputs
|
||||||
|
answerOutcome?: string
|
||||||
|
}) {
|
||||||
|
const { contract, betsByCurrentUser, comments, answerOutcome } = props
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
|
const [focused, setFocused] = useState(false)
|
||||||
|
|
||||||
async function submitComment() {
|
// Should this be oldest bet or most recent bet?
|
||||||
if (!comment) return
|
const mostRecentCommentableBet = betsByCurrentUser
|
||||||
|
.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(betId: string | undefined) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return await firebaseLogin()
|
return await firebaseLogin()
|
||||||
}
|
}
|
||||||
await createComment(contract.id, comment, user)
|
if (!comment) return
|
||||||
|
await createComment(contract.id, comment, user, betId, answerOutcome)
|
||||||
setComment('')
|
setComment('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } =
|
||||||
|
getBettorsPosition(contract, Date.now(), betsByCurrentUser)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Row className={'flex w-full gap-2 pt-5'}>
|
<Row className={'flex w-full gap-2'}>
|
||||||
<div>
|
<div>
|
||||||
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
|
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
|
||||||
</div>
|
</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">
|
<div className="text-sm text-gray-500">
|
||||||
|
{mostRecentCommentableBet && (
|
||||||
|
<BetStatusText
|
||||||
|
contract={contract}
|
||||||
|
bet={mostRecentCommentableBet}
|
||||||
|
isSelf={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!mostRecentCommentableBet && user && userPosition > 0 && (
|
||||||
|
<>
|
||||||
|
{'You have ' + userPositionMoney + ' '}
|
||||||
|
<>
|
||||||
|
{' of '}
|
||||||
|
<OutcomeLabel
|
||||||
|
outcome={yesFloorShares > noFloorShares ? 'YES' : 'NO'}
|
||||||
|
contract={contract}
|
||||||
|
truncate="short"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(answerOutcome === undefined || focused) && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={comment}
|
value={comment}
|
||||||
onChange={(e) => setComment(e.target.value)}
|
onChange={(e) => setComment(e.target.value)}
|
||||||
className="textarea textarea-bordered w-full resize-none"
|
className="textarea textarea-bordered w-full resize-none"
|
||||||
placeholder="Add a comment..."
|
placeholder="Add a comment..."
|
||||||
rows={3}
|
autoFocus={focused}
|
||||||
|
rows={answerOutcome == undefined || focused ? 3 : 1}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onBlur={() => !comment && setFocused(false)}
|
||||||
maxLength={MAX_COMMENT_LENGTH}
|
maxLength={MAX_COMMENT_LENGTH}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
submitComment()
|
submitComment(id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!user && (
|
||||||
<button
|
<button
|
||||||
className={
|
className={
|
||||||
'btn btn-outline btn-sm text-transform: mt-1 capitalize'
|
'btn btn-outline btn-sm text-transform: mt-1 capitalize'
|
||||||
}
|
}
|
||||||
onClick={submitComment}
|
onClick={() => submitComment(id)}
|
||||||
>
|
>
|
||||||
{user ? 'Comment' : 'Sign in to comment'}
|
Sign in to Comment
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
{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>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: {
|
export function FeedBet(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bet: Bet
|
bet: Bet
|
||||||
|
@ -242,22 +412,13 @@ export function FeedBet(props: {
|
||||||
bettor?: User // If set: reveal bettor identity
|
bettor?: User // If set: reveal bettor identity
|
||||||
}) {
|
}) {
|
||||||
const { contract, bet, hideOutcome, smallAvatar, bettor } = props
|
const { contract, bet, hideOutcome, smallAvatar, bettor } = props
|
||||||
const { id, amount, outcome, createdTime, userId } = bet
|
const { userId } = bet
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const isSelf = user?.id === userId
|
const isSelf = user?.id === userId
|
||||||
const canComment = canCommentOnBet(userId, createdTime, user)
|
|
||||||
|
|
||||||
const [comment, setComment] = useState('')
|
|
||||||
async function submitComment() {
|
|
||||||
if (!user || !comment || !canComment) return
|
|
||||||
await createComment(contract.id, comment, user, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const bought = amount >= 0 ? 'bought' : 'sold'
|
|
||||||
const money = formatMoney(Math.abs(amount))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Row className={'flex w-full gap-2 pt-3'}>
|
||||||
<div>
|
<div>
|
||||||
{isSelf ? (
|
{isSelf ? (
|
||||||
<Avatar
|
<Avatar
|
||||||
|
@ -276,15 +437,45 @@ export function FeedBet(props: {
|
||||||
) : (
|
) : (
|
||||||
<div className="relative px-1">
|
<div className="relative px-1">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
|
<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" />
|
<UserIcon
|
||||||
|
className="h-5 w-5 text-gray-500"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={'min-w-0 flex-1 py-1.5'}>
|
<div className={'min-w-0 flex-1 py-1.5'}>
|
||||||
|
<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">
|
<div className="text-sm text-gray-500">
|
||||||
<span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span>{' '}
|
<span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span> {bought}{' '}
|
||||||
{bought} {money}
|
{money}
|
||||||
{!hideOutcome && (
|
{!hideOutcome && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
|
@ -297,33 +488,7 @@ export function FeedBet(props: {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<RelativeTimestamp time={createdTime} />
|
<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>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -433,14 +598,11 @@ export function FeedQuestion(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function canCommentOnBet(
|
function canCommentOnBet(bet: Bet, user?: User | null) {
|
||||||
userId: string,
|
const { userId, createdTime, isRedemption } = bet
|
||||||
createdTime: number,
|
|
||||||
user?: User | null
|
|
||||||
) {
|
|
||||||
const isSelf = user?.id === userId
|
const isSelf = user?.id === userId
|
||||||
// You can comment if your bet was posted in the last hour
|
// You can comment if your bet was posted in the last hour
|
||||||
return isSelf && Date.now() - createdTime < 60 * 60 * 1000
|
return !isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedDescription(props: { contract: Contract }) {
|
function FeedDescription(props: { contract: Contract }) {
|
||||||
|
|
|
@ -9,11 +9,7 @@ const MAX_ACTIVE_CONTRACTS = 75
|
||||||
// TODO: Maybe store last activity time directly in the contract?
|
// TODO: Maybe store last activity time directly in the contract?
|
||||||
// Pros: simplifies this code; cons: harder to tweak "activity" definition later
|
// Pros: simplifies this code; cons: harder to tweak "activity" definition later
|
||||||
function lastActivityTime(contract: Contract) {
|
function lastActivityTime(contract: Contract) {
|
||||||
return Math.max(
|
return Math.max(contract.resolutionTime || 0, contract.createdTime)
|
||||||
contract.resolutionTime || 0,
|
|
||||||
contract.lastUpdatedTime,
|
|
||||||
contract.createdTime
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Types of activity to surface:
|
// Types of activity to surface:
|
||||||
|
|
|
@ -3,8 +3,8 @@ import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
MenuAlt3Icon,
|
MenuAlt3Icon,
|
||||||
|
PresentationChartLineIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
TableIcon,
|
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@heroicons/react/outline'
|
} from '@heroicons/react/outline'
|
||||||
import { Transition, Dialog } from '@headlessui/react'
|
import { Transition, Dialog } from '@headlessui/react'
|
||||||
|
@ -39,7 +39,10 @@ export function BottomNavBar() {
|
||||||
{user !== null && (
|
{user !== null && (
|
||||||
<Link href="/portfolio">
|
<Link href="/portfolio">
|
||||||
<a className="block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700">
|
<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
|
Portfolio
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -5,9 +5,10 @@ import {
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
ChatIcon,
|
ChatIcon,
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
TableIcon,
|
|
||||||
DotsHorizontalIcon,
|
DotsHorizontalIcon,
|
||||||
CashIcon,
|
CashIcon,
|
||||||
|
HeartIcon,
|
||||||
|
PresentationChartLineIcon,
|
||||||
} from '@heroicons/react/outline'
|
} from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
|
@ -24,7 +25,8 @@ import { useHasCreatedContractToday } from '../../hooks/use-has-created-contract
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||||
{ name: 'Explore', href: '/markets', icon: SearchIcon },
|
{ 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 = [
|
const signedOutNavigation = [
|
||||||
|
@ -34,6 +36,7 @@ const signedOutNavigation = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const signedOutMobileNavigation = [
|
const signedOutMobileNavigation = [
|
||||||
|
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
||||||
{ name: 'Leaderboards', href: '/leaderboards', icon: CakeIcon },
|
{ name: 'Leaderboards', href: '/leaderboards', icon: CakeIcon },
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon },
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon },
|
||||||
{ name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon },
|
{ name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon },
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
FullContract,
|
FullContract,
|
||||||
} from '../../common/contract'
|
} from '../../common/contract'
|
||||||
import { formatPercent } from '../../common/util/format'
|
import { formatPercent } from '../../common/util/format'
|
||||||
|
import { ClientRender } from './client-render'
|
||||||
|
|
||||||
export function OutcomeLabel(props: {
|
export function OutcomeLabel(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -72,11 +73,13 @@ export function FreeResponseOutcomeLabel(props: {
|
||||||
const chosen = answers?.find((answer) => answer.id === resolution)
|
const chosen = answers?.find((answer) => answer.id === resolution)
|
||||||
if (!chosen) return <AnswerNumberLabel number={resolution} />
|
if (!chosen) return <AnswerNumberLabel number={resolution} />
|
||||||
return (
|
return (
|
||||||
|
<FreeResponseAnswerToolTip text={chosen.text}>
|
||||||
<AnswerLabel
|
<AnswerLabel
|
||||||
answer={chosen}
|
answer={chosen}
|
||||||
truncate={truncate}
|
truncate={truncate}
|
||||||
className={answerClassName}
|
className={answerClassName}
|
||||||
/>
|
/>
|
||||||
|
</FreeResponseAnswerToolTip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,5 +127,32 @@ export function AnswerLabel(props: {
|
||||||
truncated = text.slice(0, 75) + '...'
|
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: {
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,7 +32,7 @@ export function Page(props: {
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{/* If right sidebar is hidden, place its content at the bottom of the page. */}
|
{/* 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>
|
</main>
|
||||||
<aside className="hidden xl:col-span-3 xl:block">
|
<aside className="hidden xl:col-span-3 xl:block">
|
||||||
<div className="sticky top-4 space-y-4">{rightSidebar}</div>
|
<div className="sticky top-4 space-y-4">{rightSidebar}</div>
|
||||||
|
|
|
@ -4,11 +4,8 @@ export function ProbabilitySelector(props: {
|
||||||
probabilityInt: number
|
probabilityInt: number
|
||||||
setProbabilityInt: (p: number) => void
|
setProbabilityInt: (p: number) => void
|
||||||
isSubmitting?: boolean
|
isSubmitting?: boolean
|
||||||
minProb?: number
|
|
||||||
maxProb?: number
|
|
||||||
}) {
|
}) {
|
||||||
const { probabilityInt, setProbabilityInt, isSubmitting, minProb, maxProb } =
|
const { probabilityInt, setProbabilityInt, isSubmitting } = props
|
||||||
props
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="items-center gap-2">
|
<Row className="items-center gap-2">
|
||||||
|
@ -18,28 +15,19 @@ export function ProbabilitySelector(props: {
|
||||||
value={probabilityInt}
|
value={probabilityInt}
|
||||||
className="input input-bordered input-md text-lg"
|
className="input input-bordered input-md text-lg"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
min={minProb ?? 1}
|
min={1}
|
||||||
max={maxProb ?? 99}
|
max={99}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setProbabilityInt(parseInt(e.target.value.substring(0, 2)))
|
setProbabilityInt(parseInt(e.target.value.substring(0, 2)))
|
||||||
}
|
}
|
||||||
onBlur={() =>
|
|
||||||
setProbabilityInt(
|
|
||||||
maxProb && probabilityInt > maxProb
|
|
||||||
? maxProb
|
|
||||||
: minProb && probabilityInt < minProb
|
|
||||||
? minProb
|
|
||||||
: probabilityInt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<span>%</span>
|
<span>%</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
className="range range-primary"
|
className="range range-primary"
|
||||||
min={minProb ?? 1}
|
min={1}
|
||||||
max={maxProb ?? 99}
|
max={99}
|
||||||
value={probabilityInt}
|
value={probabilityInt}
|
||||||
onChange={(e) => setProbabilityInt(parseInt(e.target.value))}
|
onChange={(e) => setProbabilityInt(parseInt(e.target.value))}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -72,9 +72,9 @@ export function ResolutionPanel(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={clsx('rounded-md bg-white px-8 py-6', className)}>
|
<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
|
<YesNoCancelSelector
|
||||||
className="mx-auto my-2"
|
className="mx-auto my-2"
|
||||||
|
|
26
web/components/share-market.tsx
Normal file
26
web/components/share-market.tsx
Normal file
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -26,10 +26,8 @@ export const useSaveShares = (
|
||||||
_.sumBy(noBets, (bet) => bet.shares),
|
_.sumBy(noBets, (bet) => bet.shares),
|
||||||
]
|
]
|
||||||
|
|
||||||
const [yesFloorShares, noFloorShares] = [
|
const yesFloorShares = Math.round(yesShares) === 0 ? 0 : Math.floor(yesShares)
|
||||||
Math.floor(yesShares),
|
const noFloorShares = Math.round(noShares) === 0 ? 0 : Math.floor(noShares)
|
||||||
Math.floor(noShares),
|
|
||||||
]
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Save yes and no shares to local storage.
|
// Save yes and no shares to local storage.
|
||||||
|
|
|
@ -1,213 +1,60 @@
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { Contract } from '../../common/contract'
|
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 { useTimeSinceFirstRender } from './use-time-since-first-render'
|
||||||
import { trackLatency } from '../lib/firebase/tracking'
|
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 = (
|
export const useAlgoFeed = (user: User | null | undefined) => {
|
||||||
user: User | null | undefined,
|
const [feed, setFeed] = useState<feed>()
|
||||||
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[]>([])
|
|
||||||
|
|
||||||
const getTime = useTimeSinceFirstRender()
|
const getTime = useTimeSinceFirstRender()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (user) {
|
||||||
initialContracts &&
|
getUserFeed(user.id).then((feed) => {
|
||||||
initialBets &&
|
if (feed.length === 0) {
|
||||||
initialComments &&
|
getDefaultFeed().then((feed) => setFeed(feed))
|
||||||
yourBetContractIds
|
} else setFeed(feed)
|
||||||
) {
|
|
||||||
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())
|
trackLatency('feed', getTime())
|
||||||
}
|
console.log('feed load time', getTime())
|
||||||
}, [
|
|
||||||
initialBets,
|
|
||||||
initialComments,
|
|
||||||
initialContracts,
|
|
||||||
seenContracts,
|
|
||||||
yourBetContractIds,
|
|
||||||
getTime,
|
|
||||||
])
|
|
||||||
|
|
||||||
return algoFeed
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAlgoFeed = (
|
|
||||||
contracts: Contract[],
|
|
||||||
recentBets: Bet[],
|
|
||||||
recentComments: Comment[],
|
|
||||||
yourBetContractIds: string[],
|
|
||||||
seenContracts: { [contractId: string]: number }
|
|
||||||
) => {
|
|
||||||
const contractsById = _.keyBy(contracts, (c) => c.id)
|
|
||||||
|
|
||||||
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]
|
|
||||||
})
|
})
|
||||||
)
|
}
|
||||||
|
}, [user?.id])
|
||||||
|
|
||||||
const seenScores = _.fromPairs(
|
return useUpdateFeed(feed)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getContractsActivityScores(
|
const useUpdateFeed = (feed: feed | undefined) => {
|
||||||
contracts: Contract[],
|
const contracts = useUpdatedContracts(feed?.map((item) => item.contract))
|
||||||
recentComments: Comment[],
|
|
||||||
recentBets: Bet[],
|
return feed && contracts
|
||||||
seenContracts: { [contractId: string]: number }
|
? feed.map(({ contract, ...other }, i) => ({
|
||||||
) {
|
...other,
|
||||||
const contractBets = _.groupBy(recentBets, (bet) => bet.contractId)
|
contract: contracts[i],
|
||||||
const contractMostRecentBet = _.mapValues(
|
}))
|
||||||
contractBets,
|
: undefined
|
||||||
(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))
|
||||||
)
|
)
|
||||||
|
return feed
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
13
web/hooks/use-charity-txns.ts
Normal file
13
web/hooks/use-charity-txns.ts
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
listenForActiveContracts,
|
listenForActiveContracts,
|
||||||
|
listenForContract,
|
||||||
listenForContracts,
|
listenForContracts,
|
||||||
listenForHotContracts,
|
listenForHotContracts,
|
||||||
listenForInactiveContracts,
|
listenForInactiveContracts,
|
||||||
|
@ -71,3 +72,36 @@ export const useHotContracts = () => {
|
||||||
|
|
||||||
return hotContracts
|
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
|
||||||
|
}
|
||||||
|
|
20
web/lib/api/cors.ts
Normal file
20
web/lib/api/cors.ts
Normal file
|
@ -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 = {}
|
|
@ -1,5 +1,6 @@
|
||||||
import { httpsCallable } from 'firebase/functions'
|
import { httpsCallable } from 'firebase/functions'
|
||||||
import { Fold } from '../../../common/fold'
|
import { Fold } from '../../../common/fold'
|
||||||
|
import { Txn } from '../../../common/txn'
|
||||||
import { User } from '../../../common/user'
|
import { User } from '../../../common/user'
|
||||||
import { randomString } from '../../../common/util/random'
|
import { randomString } from '../../../common/util/random'
|
||||||
import './init'
|
import './init'
|
||||||
|
@ -15,6 +16,11 @@ export const createFold = cloudFunction<
|
||||||
{ status: 'error' | 'success'; message?: string; fold?: Fold }
|
{ status: 'error' | 'success'; message?: string; fold?: Fold }
|
||||||
>('createFold')
|
>('createFold')
|
||||||
|
|
||||||
|
export const transact = cloudFunction<
|
||||||
|
Omit<Txn, 'id' | 'createdTime'>,
|
||||||
|
{ status: 'error' | 'success'; message?: string; txn?: Txn }
|
||||||
|
>('transact')
|
||||||
|
|
||||||
export const placeBet = cloudFunction('placeBet')
|
export const placeBet = cloudFunction('placeBet')
|
||||||
|
|
||||||
export const sellBet = cloudFunction('sellBet')
|
export const sellBet = cloudFunction('sellBet')
|
||||||
|
|
|
@ -21,7 +21,8 @@ export async function createComment(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
text: string,
|
text: string,
|
||||||
commenter: User,
|
commenter: User,
|
||||||
betId?: string
|
betId?: string,
|
||||||
|
answerOutcome?: string
|
||||||
) {
|
) {
|
||||||
const ref = betId
|
const ref = betId
|
||||||
? doc(getCommentsCollection(contractId), betId)
|
? doc(getCommentsCollection(contractId), betId)
|
||||||
|
@ -39,6 +40,9 @@ export async function createComment(
|
||||||
if (betId) {
|
if (betId) {
|
||||||
comment.betId = betId
|
comment.betId = betId
|
||||||
}
|
}
|
||||||
|
if (answerOutcome) {
|
||||||
|
comment.answerOutcome = answerOutcome
|
||||||
|
}
|
||||||
return await setDoc(ref, comment)
|
return await setDoc(ref, comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,10 @@ import { createRNG, shuffle } from '../../../common/util/random'
|
||||||
import { getCpmmProbability } from '../../../common/calculate-cpmm'
|
import { getCpmmProbability } from '../../../common/calculate-cpmm'
|
||||||
import { formatMoney, formatPercent } from '../../../common/util/format'
|
import { formatMoney, formatPercent } from '../../../common/util/format'
|
||||||
import { DAY_MS } from '../../../common/util/time'
|
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 type { Contract }
|
||||||
|
|
||||||
export function contractPath(contract: Contract) {
|
export function contractPath(contract: Contract) {
|
||||||
|
@ -33,6 +37,10 @@ export function homeContractPath(contract: Contract) {
|
||||||
return `/home?c=${contract.slug}`
|
return `/home?c=${contract.slug}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function contractUrl(contract: Contract) {
|
||||||
|
return `https://${ENV_CONFIG.domain}${contractPath(contract)}`
|
||||||
|
}
|
||||||
|
|
||||||
export function contractMetrics(contract: Contract) {
|
export function contractMetrics(contract: Contract) {
|
||||||
const { createdTime, resolutionTime, isResolved } = contract
|
const { createdTime, resolutionTime, isResolved } = contract
|
||||||
|
|
||||||
|
@ -231,6 +239,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(
|
const closingSoonQuery = query(
|
||||||
contractCollection,
|
contractCollection,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
|
@ -276,3 +294,33 @@ export async function getDailyContracts(
|
||||||
|
|
||||||
return contractsByDay
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
29
web/lib/firebase/txns.ts
Normal file
29
web/lib/firebase/txns.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { collection, query, where, orderBy } from 'firebase/firestore'
|
||||||
|
import _ from 'lodash'
|
||||||
|
import { Txn } from '../../../common/txn'
|
||||||
|
|
||||||
|
import { db } from './init'
|
||||||
|
import { getValues, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const charitiesQuery = query(txnCollection, where('toType', '==', 'CHARITY'))
|
||||||
|
|
||||||
|
export function getAllCharityTxns() {
|
||||||
|
return getValues<Txn>(charitiesQuery)
|
||||||
|
}
|
|
@ -23,8 +23,11 @@ import _ from 'lodash'
|
||||||
import { app } from './init'
|
import { app } from './init'
|
||||||
import { PrivateUser, User } from '../../../common/user'
|
import { PrivateUser, User } from '../../../common/user'
|
||||||
import { createUser } from './api-call'
|
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 { DAY_MS } from '../../../common/util/time'
|
||||||
|
import { Contract } from './contracts'
|
||||||
|
import { Bet } from './bets'
|
||||||
|
import { Comment } from './comments'
|
||||||
|
|
||||||
export type { User }
|
export type { User }
|
||||||
|
|
||||||
|
@ -207,3 +210,15 @@ export async function getDailyNewUsers(
|
||||||
|
|
||||||
return usersByDay
|
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 ?? []
|
||||||
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ module.exports = {
|
||||||
optimizeCss: true,
|
optimizeCss: true,
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
domains: ['lh3.googleusercontent.com'],
|
domains: ['lh3.googleusercontent.com', 'i.imgur.com'],
|
||||||
},
|
},
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
"@nivo/core": "0.74.0",
|
"@nivo/core": "0.74.0",
|
||||||
"@nivo/line": "0.74.0",
|
"@nivo/line": "0.74.0",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"daisyui": "1.16.4",
|
"daisyui": "1.16.4",
|
||||||
"dayjs": "1.10.7",
|
"dayjs": "1.10.7",
|
||||||
"firebase": "9.6.0",
|
"firebase": "9.6.0",
|
||||||
|
@ -36,6 +37,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "0.4.0",
|
"@tailwindcss/forms": "0.4.0",
|
||||||
|
"@tailwindcss/line-clamp": "^0.3.1",
|
||||||
"@tailwindcss/typography": "^0.5.1",
|
"@tailwindcss/typography": "^0.5.1",
|
||||||
"@types/lodash": "4.14.178",
|
"@types/lodash": "4.14.178",
|
||||||
"@types/node": "16.11.11",
|
"@types/node": "16.11.11",
|
||||||
|
|
|
@ -168,8 +168,7 @@ export function ContractPageContent(props: FirstArgument<typeof ContractPage>) {
|
||||||
comments={comments ?? []}
|
comments={comments ?? []}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{outcomeType === 'FREE_RESPONSE' &&
|
{outcomeType === 'FREE_RESPONSE' && (
|
||||||
(!isResolved || resolution === 'MKT') && (
|
|
||||||
<>
|
<>
|
||||||
<Spacer h={4} />
|
<Spacer h={4} />
|
||||||
<AnswersPanel
|
<AnswersPanel
|
||||||
|
@ -179,7 +178,7 @@ export function ContractPageContent(props: FirstArgument<typeof ContractPage>) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{contract.isResolved && (
|
{isResolved && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2">
|
||||||
<ContractLeaderboard contract={contract} bets={bets} />
|
<ContractLeaderboard contract={contract} bets={bets} />
|
||||||
|
@ -287,7 +286,7 @@ function ContractTopTrades(props: {
|
||||||
<FeedComment
|
<FeedComment
|
||||||
contract={contract}
|
contract={contract}
|
||||||
comment={commentsById[topCommentId]}
|
comment={commentsById[topCommentId]}
|
||||||
bet={betsById[topCommentId]}
|
betsBySameUser={[betsById[topCommentId]]}
|
||||||
hideOutcome={false}
|
hideOutcome={false}
|
||||||
truncate={false}
|
truncate={false}
|
||||||
smallAvatar={false}
|
smallAvatar={false}
|
||||||
|
@ -324,8 +323,14 @@ function ContractTopTrades(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOpenGraphProps = (contract: Contract) => {
|
const getOpenGraphProps = (contract: Contract) => {
|
||||||
const { resolution, question, creatorName, creatorUsername, outcomeType } =
|
const {
|
||||||
contract
|
resolution,
|
||||||
|
question,
|
||||||
|
creatorName,
|
||||||
|
creatorUsername,
|
||||||
|
outcomeType,
|
||||||
|
creatorAvatarUrl,
|
||||||
|
} = contract
|
||||||
const probPercent =
|
const probPercent =
|
||||||
outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined
|
outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined
|
||||||
|
|
||||||
|
@ -339,8 +344,9 @@ const getOpenGraphProps = (contract: Contract) => {
|
||||||
question,
|
question,
|
||||||
probability: probPercent,
|
probability: probPercent,
|
||||||
metadata: contractTextDetails(contract),
|
metadata: contractTextDetails(contract),
|
||||||
creatorName: creatorName,
|
creatorName,
|
||||||
creatorUsername: creatorUsername,
|
creatorUsername,
|
||||||
|
creatorAvatarUrl,
|
||||||
description,
|
description,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@ import { getDailyNewUsers } from '../lib/firebase/users'
|
||||||
|
|
||||||
export const getStaticProps = fromPropz(getStaticPropz)
|
export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz() {
|
export async function getStaticPropz() {
|
||||||
const numberOfDays = 80
|
const numberOfDays = 90
|
||||||
const today = dayjs(dayjs().format('YYYY-MM-DD'))
|
const today = dayjs(dayjs().format('YYYY-MM-DD'))
|
||||||
const startDate = today.subtract(numberOfDays, 'day')
|
const startDate = today.subtract(numberOfDays, 'day')
|
||||||
|
|
||||||
|
@ -367,7 +367,7 @@ export function CustomAnalytics(props: {
|
||||||
|
|
||||||
<Title text="Ratio of Active Users" />
|
<Title text="Ratio of Active Users" />
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultIndex={0}
|
defaultIndex={1}
|
||||||
tabs={[
|
tabs={[
|
||||||
{
|
{
|
||||||
title: 'Daily / Weekly',
|
title: 'Daily / Weekly',
|
||||||
|
|
|
@ -3,11 +3,13 @@ import { Bet, listAllBets } from '../../../../lib/firebase/bets'
|
||||||
import { listAllComments } from '../../../../lib/firebase/comments'
|
import { listAllComments } from '../../../../lib/firebase/comments'
|
||||||
import { getContractFromId } from '../../../../lib/firebase/contracts'
|
import { getContractFromId } from '../../../../lib/firebase/contracts'
|
||||||
import { FullMarket, ApiError, toLiteMarket } from '../_types'
|
import { FullMarket, ApiError, toLiteMarket } from '../_types'
|
||||||
|
import { applyCorsHeaders, CORS_UNRESTRICTED } from '../../../../lib/api/cors'
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse<FullMarket | ApiError>
|
res: NextApiResponse<FullMarket | ApiError>
|
||||||
) {
|
) {
|
||||||
|
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
|
||||||
const { id } = req.query
|
const { id } = req.query
|
||||||
const contractId = id as string
|
const contractId = id as string
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import { listAllContracts } from '../../../lib/firebase/contracts'
|
import { listAllContracts } from '../../../lib/firebase/contracts'
|
||||||
import { toLiteMarket } from './_types'
|
import { toLiteMarket } from './_types'
|
||||||
|
import { applyCorsHeaders, CORS_UNRESTRICTED } from '../../../lib/api/cors'
|
||||||
|
|
||||||
type Data = any[]
|
type Data = any[]
|
||||||
|
|
||||||
|
@ -9,6 +10,7 @@ export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse<Data>
|
res: NextApiResponse<Data>
|
||||||
) {
|
) {
|
||||||
|
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
|
||||||
const contracts = await listAllContracts()
|
const contracts = await listAllContracts()
|
||||||
// Serve from Vercel cache, then update. see https://vercel.com/docs/concepts/functions/edge-caching
|
// 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')
|
res.setHeader('Cache-Control', 's-maxage=1, stale-while-revalidate')
|
||||||
|
|
|
@ -3,11 +3,13 @@ import { Bet, listAllBets } from '../../../../lib/firebase/bets'
|
||||||
import { listAllComments } from '../../../../lib/firebase/comments'
|
import { listAllComments } from '../../../../lib/firebase/comments'
|
||||||
import { getContractFromSlug } from '../../../../lib/firebase/contracts'
|
import { getContractFromSlug } from '../../../../lib/firebase/contracts'
|
||||||
import { FullMarket, ApiError, toLiteMarket } from '../_types'
|
import { FullMarket, ApiError, toLiteMarket } from '../_types'
|
||||||
|
import { applyCorsHeaders, CORS_UNRESTRICTED } from '../../../../lib/api/cors'
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse<FullMarket | ApiError>
|
res: NextApiResponse<FullMarket | ApiError>
|
||||||
) {
|
) {
|
||||||
|
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
|
||||||
const { slug } = req.query
|
const { slug } = req.query
|
||||||
|
|
||||||
const contract = await getContractFromSlug(slug as string)
|
const contract = await getContractFromSlug(slug as string)
|
||||||
|
|
242
web/pages/charity/[charitySlug].tsx
Normal file
242
web/pages/charity/[charitySlug].tsx
Normal file
|
@ -0,0 +1,242 @@
|
||||||
|
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'
|
||||||
|
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' })
|
||||||
|
|
||||||
|
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 newToOld = _.sortBy(txns, (txn) => -txn.createdTime)
|
||||||
|
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
|
||||||
|
|
||||||
|
const { width, height } = useWindowSize()
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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} />
|
||||||
|
{/* TODO: donations over time chart */}
|
||||||
|
<Row className="justify-between">
|
||||||
|
{photo && (
|
||||||
|
<div className="relative w-40 rounded-2xl">
|
||||||
|
<Image src={photo} alt="" layout="fill" objectFit="contain" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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} />
|
||||||
|
{newToOld.map((txn) => (
|
||||||
|
<Donation key={txn.id} txn={txn} />
|
||||||
|
))}
|
||||||
|
</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(
|
||||||
|
'whitespace-pre-line 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 && 'invisible'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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
|
||||||
|
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>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
setShowConfetti(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-white py-6 px-8 shadow-lg">
|
||||||
|
<Title text="Donate" className="!mt-0" />
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<label
|
||||||
|
className="mb-2 block text-sm text-gray-500"
|
||||||
|
htmlFor="donate-input"
|
||||||
|
>
|
||||||
|
Amount
|
||||||
|
</label>
|
||||||
|
<BuyAmountInput
|
||||||
|
inputClassName="w-full max-w-none donate-input"
|
||||||
|
amount={amount}
|
||||||
|
onChange={setAmount}
|
||||||
|
error={error}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Col className="mt-3 w-full gap-3">
|
||||||
|
<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 */}
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Spacer h={8} />
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={clsx(
|
||||||
|
'btn w-full',
|
||||||
|
donateDisabled ? 'btn-disabled' : 'btn-primary',
|
||||||
|
isSubmitting && 'loading'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Donate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
110
web/pages/charity/index.tsx
Normal file
110
web/pages/charity/index.tsx
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import _ from 'lodash'
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
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 { getAllCharityTxns } from '../../lib/firebase/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))
|
||||||
|
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(
|
||||||
|
() =>
|
||||||
|
charities.filter((charity) =>
|
||||||
|
charity.name.toLowerCase().includes(query.toLowerCase())
|
||||||
|
),
|
||||||
|
[query]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<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">
|
||||||
|
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
|
||||||
|
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) => (
|
||||||
|
<CharityCard charity={charity} key={charity.name} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filterCharities.length === 0 && (
|
||||||
|
<div className="text-center text-gray-500">
|
||||||
|
😢 We couldn't find that charity...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<iframe
|
||||||
|
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 w-full rounded-xl bg-white p-10"
|
||||||
|
></iframe>
|
||||||
|
|
||||||
|
<div className="mt-10 text-gray-500">
|
||||||
|
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">
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -110,14 +110,11 @@ export function NewContract(props: { question: string; tag?: string }) {
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
const boundedProb =
|
|
||||||
initialProb > 90 ? 90 : initialProb < 10 ? 10 : initialProb
|
|
||||||
|
|
||||||
const result: any = await createContract({
|
const result: any = await createContract({
|
||||||
question,
|
question,
|
||||||
outcomeType,
|
outcomeType,
|
||||||
description,
|
description,
|
||||||
initialProb: boundedProb,
|
initialProb,
|
||||||
ante,
|
ante,
|
||||||
closeTime,
|
closeTime,
|
||||||
tags,
|
tags,
|
||||||
|
@ -179,8 +176,6 @@ export function NewContract(props: { question: string; tag?: string }) {
|
||||||
<ProbabilitySelector
|
<ProbabilitySelector
|
||||||
probabilityInt={initialProb}
|
probabilityInt={initialProb}
|
||||||
setProbabilityInt={setInitialProb}
|
setProbabilityInt={setInitialProb}
|
||||||
minProb={10}
|
|
||||||
maxProb={90}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
import { Fold } from '../../../../common/fold'
|
import { Fold } from '../../../../common/fold'
|
||||||
import { Comment } from '../../../../common/comment'
|
import { Comment } from '../../../../common/comment'
|
||||||
|
@ -23,22 +22,17 @@ import { useUser } from '../../../hooks/use-user'
|
||||||
import { useFold } from '../../../hooks/use-fold'
|
import { useFold } from '../../../hooks/use-fold'
|
||||||
import { SearchableGrid } from '../../../components/contract/contracts-list'
|
import { SearchableGrid } from '../../../components/contract/contracts-list'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import clsx from 'clsx'
|
|
||||||
import { scoreCreators, scoreTraders } from '../../../../common/scoring'
|
import { scoreCreators, scoreTraders } from '../../../../common/scoring'
|
||||||
import { Leaderboard } from '../../../components/leaderboard'
|
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 { EditFoldButton } from '../../../components/folds/edit-fold-button'
|
||||||
import Custom404 from '../../404'
|
import Custom404 from '../../404'
|
||||||
import { FollowFoldButton } from '../../../components/folds/follow-fold-button'
|
import { FollowFoldButton } from '../../../components/folds/follow-fold-button'
|
||||||
import FeedCreate from '../../../components/feed-create'
|
|
||||||
import { SEO } from '../../../components/SEO'
|
import { SEO } from '../../../components/SEO'
|
||||||
import { useTaggedContracts } from '../../../hooks/use-contracts'
|
import { useTaggedContracts } from '../../../hooks/use-contracts'
|
||||||
import { Linkify } from '../../../components/linkify'
|
import { Linkify } from '../../../components/linkify'
|
||||||
import { fromPropz, usePropz } from '../../../hooks/use-propz'
|
import { fromPropz, usePropz } from '../../../hooks/use-propz'
|
||||||
import { filterDefined } from '../../../../common/util/array'
|
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 { findActiveContracts } from '../../../components/feed/find-active-contracts'
|
||||||
import { Tabs } from '../../../components/layout/tabs'
|
import { Tabs } from '../../../components/layout/tabs'
|
||||||
|
|
||||||
|
@ -149,12 +143,6 @@ export default function FoldPage(props: {
|
||||||
const contracts = filterDefined(
|
const contracts = filterDefined(
|
||||||
props.contracts.map((contract) => contractsMap[contract.id])
|
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]) {
|
if (fold === null || !foldSubpages.includes(page) || slugs[2]) {
|
||||||
return <Custom404 />
|
return <Custom404 />
|
||||||
|
@ -178,37 +166,6 @@ export default function FoldPage(props: {
|
||||||
</Col>
|
</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 = (
|
const leaderboardsTab = (
|
||||||
<Col className="gap-8 px-4 lg:flex-row">
|
<Col className="gap-8 px-4 lg:flex-row">
|
||||||
<FoldLeaderboards
|
<FoldLeaderboards
|
||||||
|
@ -248,13 +205,8 @@ export default function FoldPage(props: {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultIndex={page === 'leaderboards' ? 2 : page === 'markets' ? 1 : 0}
|
defaultIndex={page === 'leaderboards' ? 1 : 0}
|
||||||
tabs={[
|
tabs={[
|
||||||
{
|
|
||||||
title: 'Activity',
|
|
||||||
content: activityTab,
|
|
||||||
href: foldPath(fold),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Markets',
|
title: 'Markets',
|
||||||
content: <SearchableGrid contracts={contracts} />,
|
content: <SearchableGrid contracts={contracts} />,
|
||||||
|
|
|
@ -9,30 +9,19 @@ import { Spacer } from '../components/layout/spacer'
|
||||||
import { Col } from '../components/layout/col'
|
import { Col } from '../components/layout/col'
|
||||||
import { useUser } from '../hooks/use-user'
|
import { useUser } from '../hooks/use-user'
|
||||||
import { LoadingIndicator } from '../components/loading-indicator'
|
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 { useAlgoFeed } from '../hooks/use-algo-feed'
|
||||||
import { ContractPageContent } from './[username]/[contractSlug]'
|
import { ContractPageContent } from './[username]/[contractSlug]'
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
|
||||||
const contracts = useActiveContracts()
|
const feed = useAlgoFeed(user)
|
||||||
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 router = useRouter()
|
const router = useRouter()
|
||||||
const { u: username, s: slug } = router.query
|
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(() => {
|
useEffect(() => {
|
||||||
// If the page initially loads with query params, redirect to the contract page.
|
// 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]">
|
<Col className="w-full max-w-[700px]">
|
||||||
<FeedCreate user={user ?? undefined} />
|
<FeedCreate user={user ?? undefined} />
|
||||||
<Spacer h={10} />
|
<Spacer h={10} />
|
||||||
{contracts && recentBets && recentComments ? (
|
{feed ? (
|
||||||
<ActivityFeed
|
<ActivityFeed
|
||||||
contracts={updatedContracts}
|
feed={feed}
|
||||||
recentBets={recentBets}
|
|
||||||
recentComments={recentComments}
|
|
||||||
mode="only-recent"
|
mode="only-recent"
|
||||||
getContractPath={(c) =>
|
getContractPath={(c) =>
|
||||||
`home?u=${c.creatorUsername}&s=${c.slug}`
|
`home?u=${c.creatorUsername}&s=${c.slug}`
|
||||||
|
|
|
@ -6,22 +6,11 @@ import { Page } from '../components/page'
|
||||||
import { SEO } from '../components/SEO'
|
import { SEO } from '../components/SEO'
|
||||||
import { Title } from '../components/title'
|
import { Title } from '../components/title'
|
||||||
import { useContracts } from '../hooks/use-contracts'
|
import { useContracts } from '../hooks/use-contracts'
|
||||||
import { Contract, listAllContracts } from '../lib/firebase/contracts'
|
import { Contract } from '../lib/firebase/contracts'
|
||||||
|
|
||||||
export async function getStaticProps() {
|
|
||||||
const contracts = await listAllContracts().catch((_) => [])
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
contracts,
|
|
||||||
},
|
|
||||||
|
|
||||||
revalidate: 60, // regenerate after a minute
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Rename endpoint to "Explore"
|
// TODO: Rename endpoint to "Explore"
|
||||||
export default function Markets(props: { contracts: Contract[] }) {
|
export default function Markets() {
|
||||||
const contracts = useContracts() ?? props.contracts ?? []
|
const contracts = useContracts()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
@ -30,11 +19,6 @@ export default function Markets(props: { contracts: Contract[] }) {
|
||||||
description="Discover what's new, trending, or soon-to-close. Or search among our hundreds of markets."
|
description="Discover what's new, trending, or soon-to-close. Or search among our hundreds of markets."
|
||||||
url="/markets"
|
url="/markets"
|
||||||
/>
|
/>
|
||||||
{/* <HotMarkets contracts={hotContracts} />
|
|
||||||
<Spacer h={10} />
|
|
||||||
<ClosingSoonMarkets contracts={closingSoonContracts} />
|
|
||||||
<Spacer h={10} /> */}
|
|
||||||
|
|
||||||
<SearchableGrid contracts={contracts} />
|
<SearchableGrid contracts={contracts} />
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,27 +3,24 @@ import { useRouter } from 'next/router'
|
||||||
import { SearchableGrid } from '../../components/contract/contracts-list'
|
import { SearchableGrid } from '../../components/contract/contracts-list'
|
||||||
import { Page } from '../../components/page'
|
import { Page } from '../../components/page'
|
||||||
import { Title } from '../../components/title'
|
import { Title } from '../../components/title'
|
||||||
import { useContracts } from '../../hooks/use-contracts'
|
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
listTaggedContractsCaseInsensitive,
|
listTaggedContractsCaseInsensitive,
|
||||||
} from '../../lib/firebase/contracts'
|
} from '../../lib/firebase/contracts'
|
||||||
|
|
||||||
export default function TagPage(props: { contracts: Contract[] }) {
|
export default function TagPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { tag } = router.query as { tag: string }
|
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
|
// 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
|
// want to e.g. listen for new contracts you may want to fix it up
|
||||||
const [contracts, setContracts] = useState<Contract[] | 'loading'>('loading')
|
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tag != null) {
|
if (tag != null) {
|
||||||
listTaggedContractsCaseInsensitive(tag).then(setContracts)
|
listTaggedContractsCaseInsensitive(tag).then(setContracts)
|
||||||
}
|
}
|
||||||
}, [tag])
|
}, [tag])
|
||||||
|
|
||||||
if (contracts === 'loading') return <></>
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<Title text={`#${tag}`} />
|
<Title text={`#${tag}`} />
|
||||||
|
|
|
@ -22,6 +22,7 @@ module.exports = {
|
||||||
plugins: [
|
plugins: [
|
||||||
require('@tailwindcss/forms'),
|
require('@tailwindcss/forms'),
|
||||||
require('@tailwindcss/typography'),
|
require('@tailwindcss/typography'),
|
||||||
|
require('@tailwindcss/line-clamp'),
|
||||||
require('daisyui'),
|
require('daisyui'),
|
||||||
],
|
],
|
||||||
daisyui: {
|
daisyui: {
|
||||||
|
|
|
@ -906,6 +906,11 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
mini-svg-data-uri "^1.2.3"
|
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":
|
"@tailwindcss/typography@^0.5.1":
|
||||||
version "0.5.1"
|
version "0.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.1.tgz#486248a9426501f11a9b0295f7cfc0eb29659c46"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.1.tgz#486248a9426501f11a9b0295f7cfc0eb29659c46"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user