From 73fc67955d279a4d729bb295e2f4e8309ea82755 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Fri, 29 Apr 2022 16:35:56 -0700 Subject: [PATCH 01/31] Send M$ to Charity & txns (#81) * Add components for CPM landing and charity pages * Remove misc.ts to fix build * Set up cloud function for writing txns * More plumbing for txns * Fix up API call * Use Date.now() to keep timestamps simple * Some styles for charity list page * Hard code charities data * Pass charity data to charity page * Update txn type * Listen for charity txns * Handle txn to non-user by burning it * Read txns for charity card and charity page. * Set images to object contain * Clean up txn types * Move pic to top of card. Other misc styling. * Update charity short & long descriptions * Add `token` and `category` to Txn * Fix breakages * Show Charity link in the sidebar * Fix typing issues * Fix not reading from the right type * Switch out icon * Also show Charity icon on mobile * Update copy Co-authored-by: Austin Chen Co-authored-by: James Grugett --- common/charity.ts | 282 ++++++++++++++++++++++++ common/txn.ts | 21 ++ firestore.rules | 5 + functions/src/backup-db.ts | 1 + functions/src/index.ts | 1 + functions/src/transact.ts | 82 +++++++ web/components/charity/charity-card.tsx | 34 +++ web/components/nav/nav-bar.tsx | 7 +- web/components/nav/sidebar.tsx | 7 +- web/hooks/use-charity-txns.ts | 13 ++ web/lib/firebase/api-call.ts | 6 + web/lib/firebase/txns.ts | 23 ++ web/package.json | 1 + web/pages/charity/[charitySlug].tsx | 212 ++++++++++++++++++ web/pages/charity/index.tsx | 64 ++++++ web/tailwind.config.js | 1 + yarn.lock | 5 + 17 files changed, 761 insertions(+), 4 deletions(-) create mode 100644 common/charity.ts create mode 100644 common/txn.ts create mode 100644 functions/src/transact.ts create mode 100644 web/components/charity/charity-card.tsx create mode 100644 web/hooks/use-charity-txns.ts create mode 100644 web/lib/firebase/txns.ts create mode 100644 web/pages/charity/[charitySlug].tsx create mode 100644 web/pages/charity/index.tsx diff --git a/common/charity.ts b/common/charity.ts new file mode 100644 index 00000000..a4cfd551 --- /dev/null +++ b/common/charity.ts @@ -0,0 +1,282 @@ +export interface Charity { + id: string + slug: string + name: string + website: string + ein: string + photo?: string + preview: string + description: string +} + +export const charities: Charity[] = [ + { + name: 'Faunalytics', + website: 'https://faunalytics.org/', + ein: '01-0686889', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2016/08/logo-faunalytics2400x2400-200x200@2x.jpg', + preview: + 'Faunalytics conducts research and shares knowledge to help advocates help animals effectively.', + description: + "Faunalytics' mission is to empower animal advocates with access to research, analysis, strategies, and messages that maximize their effectiveness to reduce animal suffering.\n Animals need you, and you need data. We conduct essential research, maintain an online research library, and directly support advocates and organizations in their work to save lives. The range of data we offer helps our movement understand how people think about and respond to advocacy, providing advocates with the best strategies to inspire change for animals. ", + }, + { + name: 'The Humane League', + website: 'https://thehumaneleague.org/', + ein: '04-3817491', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2019/03/thl-mended-heart-logo@2x-200x200@2x.jpg', + preview: + 'We exist to end the abuse of animals raised for food by influencing the policies of the world’s biggest companies, demanding legislation, and empowering others to take action and leave animals off their plates', + description: + 'The Humane League (THL) currently operates in the U.S., Mexico, the U.K., and Japan, where they work to improve animal welfare standards through grassroots campaigns, movement building, veg*n advocacy, research, and advocacy training, as well as through corporate, media, and community outreach. They work to build the animal advocacy movement internationally through the Open Wing Alliance (OWA), a coalition founded by THL whose mission is to end the use of battery cages globally.', + }, + { + name: 'Wild Animal Initiative', + website: 'https://www.wildanimalinitiative.org/', + ein: '82-2281466', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2020/11/WAI-logo_square-gray-on-teal-1-630x630.png', + preview: 'We want to make life better for wild animals.', + description: + 'Wild Animal Initiative (WAI) currently operates in the U.S., where they work to strengthen the animal advocacy movement through creating an academic field dedicated to wild animal welfare. They compile literature reviews, write theoretical and opinion articles, and publish research results on their website and/or in peer-reviewed journals. WAI focuses on identifying and sharing possible research avenues and connecting with more established fields. They also work with researchers from various academic and non-academic institutions to identify potential collaborators, and they recently launched a grant assistance program.', + }, + { + name: 'Give Directly', + website: 'https://www.givedirectly.org/', + ein: '27-1661997', + photo: + 'https://www.givewell.org/sites/default/files/charity_logos/GiveDirectly.jpg', + preview: 'Send money directly to people living in poverty.', + description: + 'GiveDirectly is a nonprofit that lets donors like you send money directly to the world’s poorest households. We believe people living in poverty deserve the dignity to choose for themselves how best to improve their lives — cash enables that choice. Since 2009, we’ve delivered $500M+ in cash directly into the hands of over 1 million families living in poverty. We currently have operations in Kenya, Rwanda, Liberia, Malawi, Morocco, Mozambique, DRC, Uganda, and the United States.', + }, + { + name: 'Hellen Keller International', + website: 'https://www.hki.org/', + ein: '13-5562162', + photo: + 'https://www.ntd-ngonetwork.org/sites/nnn/files/content/organisation/logos/2020-01-28/v2_HKLogo_Primary_RGB.jpg', + preview: + 'At Helen Keller Intl, we envision a world where no one is deprived of the opportunity to live a healthy life – and reach their true potential.', + description: + 'Right now, 36 million people worldwide — most of them in developing countries — are blind.\n 90 percent of them didn’t have to lose their sight. Helen Keller International is dedicated to combating the causes and consequences of vision loss and making clear vision a reality for those most vulnerable to disease and who lack access to quality eye care.\n Last year alone, we helped provide many tens of millions of people with treatment to prevent diseases of poverty including blinding trachoma and river blindness.\n Surgeons trained by our staff also performed tens of thousands of cataract surgeries in the developing world.  And in the United States, we screened the vision of nearly 66,000 students living in some of our country’s poorest neighborhoods and provided free eyeglasses to just over 16,000 of them. ', + }, + { + name: 'Against Malaria Foundation', + website: 'https://www.againstmalaria.com/', + ein: '20-3069841', + photo: + 'https://media-exp1.licdn.com/dms/image/C4D0BAQFvdcum9KBNfg/company-logo_200_200/0?e=2159024400&v=beta&t=hxjJCKQkMp2irTOcuJEceW7x4l3c4PD7gYCQ6ulgYlg', + preview: 'We help protect people from malaria.', + description: + 'AMF (againstmalaria.com) provides funding for long-lasting insecticide-treated net (LLIN) distributions (for protection against malaria) in developing countries. There is strong evidence that distributing LLINs reduces child mortality and malaria cases. AMF conducts post-distribution surveys of completed distributions to determine whether LLINs have reached their intended destinations and how long they remain in good condition.', + }, + { + name: 'Malaria Consortium', + website: 'https://www.malariaconsortium.org/', + ein: '98-0627052', + photo: + 'http://www.malariaconsortium.org/website-2013/images_template/malaria_consortium_logo.png', + preview: + 'We specialise in the prevention, control and treatment of malaria and other communicable diseases.', + description: + 'We are dedicated to ensuring our work is supported by strong evidence and remains grounded in the lessons we learn through implementation. We explore beyond current practice, to try out innovative ways – through research, implementation and policy development – to achieve effective and sustainable disease management and control.', + }, + { + name: 'New Incentives', + website: 'https://www.newincentives.org/', + ein: '45-2368993', + photo: + 'https://uploads-ssl.webflow.com/5f7c51bf9fac9b5ed62aa37b/5f7c51bf9fac9b85c42aa3df_Group%20344%20(1).svg', + preview: 'Cash incentives to boost vaccination rates and save lives.', + description: + 'New Incentives (newincentives.org) runs a conditional cash transfer (CCT) program in North West Nigeria which seeks to increase uptake of routine immunizations through cash transfers, raising public awareness of the benefits of vaccination and reducing the frequency of vaccine stockouts.', + }, + { + name: 'SCI foundation', + website: 'https://schistosomiasiscontrolinitiative.org/', + ein: '', + photo: + 'https://images.easyfundraising.org.uk/cause/cropped/cause-logo-e99e0632a8a9572150fdcf3abf08ad45.png', + preview: + 'SCI works with governments in sub-Saharan Africa to create or scale up programs that treat schistosomiasis and soil-transmitted helminthiasis ("deworming").', + description: + 'We’re a non-profit initiative supporting governments in sub-Saharan African countries. We support them to develop sustainable, cost-effective programmes against parasitic worm infections such as schistosomiasis and intestinal worms.  Since our foundation in 2002, we’ve contributed to the delivery of over 200 million treatments against these diseases. The programmes are highly effective; parasitic worm infections can be reduced by up to 60% after just one round of treatment.', + }, + { + name: 'Wikimedia Foundation', + website: 'https://wikimediafoundation.org/', + ein: '20-0049703', + photo: + 'http://2.bp.blogspot.com/-jVseU39DW0s/VjmXVMOEEEI/AAAAAAAACK8/dwUP6sLqy-Q/s1600/wikimedia.png', + preview: 'We help everyone share in the sum of all knowledge.', + description: + 'We are the people who keep knowledge free. There is an amazing community of people around the world that makes great projects like Wikipedia. We help them do that work. We take care of the technical infrastructure, the legal challenges, and the growing pains.', + }, + { + name: 'Rainforest Trust', + website: 'https://www.rainforesttrust.org/', + ein: '13-3500609', + photo: + 'http://ww1.prweb.com/prfiles/2019/05/29/16344590/Rrainforest%20Trust%20new%20logo%20tall-1%20copy.png', + preview: + 'Rainforest Trust saves endangered wildlife and protects our planet by creating rainforest reserves through partnerships, community engagement and donor support.', + description: + 'Our unique, cost-effective conservation model for protecting endangered species has been implemented successfully for over 30 years. Thanks to the generosity of our donors, the expertise of our partners and the participation of local communities across the tropics, our reserves are exemplary models of international conservation.', + }, + { + name: 'The Nature Conservancy', + website: 'https://www.nature.org/en-us/', + ein: '53-0242652', + photo: + 'https://mma.prnewswire.com/media/1140905/The_Nature_Conservancy_Logo.jpg?p=facebook', + preview: 'A Future Where People and Nature Thrive', + description: + 'The Nature Conservancy is a global environmental nonprofit working to create a world where people and nature can thrive. Founded in the U.S. through grassroots action in 1951, The Nature Conservancy has grown to become one of the most effective and wide-reaching environmental organizations in the world. Thanks to more than a million members and the dedicated efforts of our diverse staff and over 400 scientists, we impact conservation in 76 countries and territories: 37 by direct conservation impact and 39 through partners.', + }, + { + name: 'Doctors Without Borders', + website: 'https://www.doctorswithoutborders.org/', + ein: '13-3433452', + photo: 'https://www.doctorswithoutborders.org/themes/custom/msf/logo.svg', + preview: + 'We provide independent, impartial medical humanitarian assistance to the people who need it most.', + description: + 'Doctors Without Borders/Médecins Sans Frontières (MSF) cares for people affected by conflict, disease outbreaks, natural and human-made disasters, and exclusion from health care in more than 70 countries.', + }, + { + name: 'World Wildlife Fund', + website: 'https://www.worldwildlife.org/', + ein: '52-1693387', + photo: + 'https://www.worldwildlife.org/assets/structure/unique/logo-c562409bb6158bf64e5f8b1be066dbd5983d75f5ce7c9935a5afffbcc03f8e5d.png', + preview: + 'WWF works to sustain the natural world for the benefit of people and wildlife, collaborating with partners from local to global levels in nearly 100 countries.', + description: + 'As the world’s leading conservation organization, WWF works in nearly 100 countries to tackle the most pressing issues at the intersection of nature, people, and climate. We collaborate with local communities to conserve the natural resources we all depend on and build a future in which people and nature thrive. Together with partners at all levels, we transform markets and policies toward sustainability, tackle the threats driving the climate crisis, and protect and restore wildlife and their habitats.', + }, + { + name: 'UNICEF USA', + website: 'https://www.unicefusa.org/', + ein: '13-1760110', + preview: + "UNICEF USA helps save and protect the world's most vulnerable children.", + description: + 'Over eight decades, the United Nations Children’s Fund (UNICEF) has built an unprecedented global support system for the world’s children. UNICEF relentlessly works day in and day out to deliver the essentials that give every child an equitable chance in life: health care and immunizations, safe water and sanitation, nutrition, education, emergency relief and more. UNICEF USA advances the global mission of UNICEF by rallying the American public to support the world’s most vulnerable children. Together, we have helped save more children’s lives than any other humanitarian organization.', + }, + { + name: 'Vitamin Angels', + website: 'https://www.vitaminangels.org/', + ein: '77-0485881', + photo: + 'https://www.newhope.com/sites/newhope360.com/files/styles/article_featured_retina/public/vitamin-angels-logo.jpg?itok=pfNCPLE0', + preview: + 'By improving access to vital nutrition, everyone gets an equal chance to grow, thrive, and prosper.', + description: + 'Our team of program experts collaborates with thousands of local organizations and national governments around the world, focusing efforts on reaching communities who are underserved. Vitamin Angels’ program partners are a local presence in these communities. As trusted organizations already hard at work, they connect millions of pregnant women and young children with our evidence-based nutrition interventions in addition to the health services they already provide.', + }, + { + name: 'Free Software Foundation', + website: 'https://www.fsf.org/', + ein: '04-2888848', + photo: 'https://www.gnu.org/graphics/logo-fsf.org.png', + preview: + 'The Free Software Foundation (FSF) is a nonprofit with a worldwide mission to promote computer user freedom.', + description: + 'As our society grows more dependent on computers, the software we run is of critical importance to securing the future of a free society. Free software is about having control over the technology we use in our homes, schools and businesses, where computers work for our individual and communal benefit, not for proprietary software companies or governments who might seek to restrict and monitor us. The Free Software Foundation exclusively uses free software to perform its work.The Free Software Foundation is working to secure freedom for computerusers by promoting the development and use of free (as in freedom) software and documentation—particularly the GNU operating system—and by campaigning against threats to computer user freedom like Digital Restrictions Management (DRM) and software patents.', + }, + { + name: 'Direct Relief', + website: 'https://www.directrelief.org/', + ein: '95-1831116', + photo: + 'https://www.ngoadvisor.net/wp-content/uploads/2016/02/DirectRelief_Logo_RGB-2-1920x576.png', + preview: + 'Direct Relief is a humanitarian aid organization, active in all 50 states and more than 80 countries, with a mission to improve the health and lives of people affected by poverty or emergencies – without regard to politics, religion, or ability to pay.', + description: + 'Nongovernmental, nonsectarian, and not-for-profit, Direct Relief relies entirely on private contributions to advance its mission and perform a wide range of functions.\n Included among them are identifying key local providers of health services; working to identify the unmet needs of people in the low-resource areas; mobilizing essential medicines, supplies, and equipment that are requested and appropriate for the circumstances; and managing the many details inherent in storing, transporting, and distributing such resources to organizations in the most efficient manner possible.', + }, + { + name: 'World Resources Institute', + website: 'https://www.wri.org/', + ein: '52-1257057', + photo: + 'https://www.americansecurityproject.org/wp-content/uploads/2016/11/WRI_logo_4c.png', + preview: + 'WRI is a global nonprofit organization that works with leaders in government, business and civil society to research, design, and carry out practical solutions that simultaneously improve people’s lives and ensure nature can thrive.', + description: + "Since its founding in 1982, WRI has been guided by its mission and core values which are integrated into all that we do. Our mission: To move human society to live in ways that protect Earth’s environment and its capacity to provide for the needs and aspirations of current and future generations. WRI relies on the generosity of our donors to drive outcomes that help the world to be a fairer, healthier and more sustainable place for people and the planet. We publish our financials annually to highlight our continued fiscal accountability. That's why WRI consistently receives top ratings from charity evaluators for our strong financial stewardship and commitment to transparency and accountability.", + }, + { + name: 'ProPublica', + website: 'https://www.propublica.org/', + ein: '14-2007220', + photo: + 'https://seekvectorlogo.com/wp-content/uploads/2018/09/propublica-vector-logo.png', + preview: + 'The mission: to expose abuses of power and betrayals of the public trust by government, business, and other institutions, using the moral force of investigative journalism to spur reform through the sustained spotlighting of wrongdoing.', + description: + 'ProPublica is an independent, nonprofit newsroom that produces investigative journalism with moral force. We dig deep into important issues, shining a light on abuses of power and betrayals of public trust — and we stick with those issues as long as it takes to hold power to account. With a team of more than 100 dedicated journalists, ProPublica covers a range of topics including government and politics, business, criminal justice, the environment, education, health care, immigration, and technology. We focus on stories with the potential to spur real-world impact. Among other positive changes, our reporting has contributed to the passage of new laws; reversals of harmful policies and practices; and accountability for leaders at local, state and national levels.', + }, + { + name: 'Dana-Farber Cancer Institute', + website: 'https://www.dana-farber.org/', + ein: '04-2263040', + photo: + 'https://www.danafarbermasterclass.com/assets/images/DFCI-logo-lens-stacked.png', + preview: + "For over 70 years, we've led the world by making life-changing breakthroughs in cancer research and patient care, providing the most advanced treatments available.", + description: + "Since its founding in 1947, Dana-Farber Cancer Institute in Boston, Massachusetts has been committed to providing adults and children with cancer with the best treatment available today while developing tomorrow's cures through cutting-edge research. Today, the Institute employs more than 5,000 staff, faculty, and clinicians supporting more than 640,000 annual outpatient visits, more than 1,000 hospital discharges per year, and has over 1,100 open clinical trials. Dana-Farber is internationally renowned for its equal commitment to cutting edge research and provision of excellent patient care. The deep expertise in these two areas uniquely positions Dana-Farber to develop, test, and gain FDA approval for new cancer therapies in its laboratories and clinical settings. Dana-Farber researchers have contributed to the development of 35 of 75 cancer drugs recently approved by the FDA for use in cancer patients.", + }, + { + name: 'Save The Children', + website: 'https://www.savethechildren.org/', + ein: '06-0726487', + photo: + 'https://www.thisisclapham.co.uk/wp-content/uploads/2016/08/savethechildren.png', + preview: + 'Through the decades, Save the Children has continued to work to save children’s lives, and that’s still what we do today.', + description: + "Our pioneering programs address children's unique needs, giving them a healthy start in life, the opportunity to learn and protection from harm. In the United States and around the world, our work creates lasting change for children, their families and communities – ultimately, transforming the future we all share.\nThis work is only made possible by the ongoing generosity of our donors, whose valuable support is used in the most cost-effective ways. It's important to note that all our work intersects – helping a boy or girl go to school also protects them from dangers such as child trafficking and early marriage. Keeping children healthy from disease or malnutrition means their parents are more likely to avoid costly treatment and be better able to provide for their family.\nWe don’t go into communities, carry out a project and then move on. We consult with children, their families, community leaders and local councils to understand all the issues or barriers, and then we develop programs that address these. We build trust so that our programs are successful and bring about real change.", + }, + { + name: 'World Central Kitchen Incorporated', + website: 'https://wck.org/', + ein: '27-3521132', + photo: + 'https://res.cloudinary.com/dktp1ybbx/image/upload/f_auto,fl_lossy,q_auto/v1560203222/organization/prod/924457/M0oxO9vaxO.png', + preview: + 'WCK is first to the frontlines, providing meals in response to humanitarian, climate, and community crises. We build resilient food systems with locally led solutions.', + description: + "WCK responds to natural disasters, man-made crises, and humanitarian emergencies around the world. We're a team of food first responders, mobilizing with the urgency of now to get meals to the people who need them most. Deploying our model of quick action, leveraging local resources, and adapting in real time, we know that a nourishing meal in a time of crisis is so much more than a plate of food—it's hope, it's dignity, and it's a sign that someone cares.", + }, + { + name: 'The Johns Hopkins Center for Health Security', + website: 'https://www.centerforhealthsecurity.org/', + ein: '', + photo: + 'https://www.centerforhealthsecurity.org/sebin/d/d/CHS.logo.horizontal.blue.png', + preview: + 'Our mission: to protect people’s health from epidemics and disasters and ensure that communities are resilient to major challenges.', + description: + 'The Center for Health Security undertakes a series of projects, collaborations, and initiatives to push forward progress on global health security, emerging infectious diseases and epidemics, medical and public health preparedness and response, deliberate biological threats, and opportunities and risks in the life sciences. We:\n- Conduct research and analysis on major domestic and international health security issues.\n- Engage with researchers, the policymaking community, and the private sector to make progress in the field.\n- Convene expert working groups, congressional seminars, scientific meetings, conferences, and tabletop exercises to stimulate new thinking and provoke action.\n- Educate a rising generation of scholars, practitioners, and policymakers.', + }, + { + name: 'ALLFED', + website: 'https://allfed.info/', + ein: '27-6601178', + preview: 'Feeding everyone no matter what.', + description: + 'The mission of the Alliance to Feed the Earth in Disasters is to help create resilience to global food shocks. We seek to identify various resilient food solutions and to help governments implement these solutions, to increase the chances that people have enough to eat in the event of a global catastrophe. We focus on events that could deplete food supplies or access to 5% of the global population or more.Our ultimate goal is to feed everyone, no matter what. An important aspect of this goal is that we need to establish equitable solutions so that all people can access the nutrition they need, regardless of wealth or location.ALLFED is inspired by effective altruism, using reason and evidence to identify how to do the most good. Our solutions are backed by science and research, and we also identify the most cost-effective solutions, to be able to provide more nutrition in catastrophes.', + }, +].map((charity) => { + const slug = charity.name.toLowerCase().replace(/\s/g, '-') + return { + ...charity, + id: slug, + slug, + } +}) diff --git a/common/txn.ts b/common/txn.ts new file mode 100644 index 00000000..8beea234 --- /dev/null +++ b/common/txn.ts @@ -0,0 +1,21 @@ +// A txn (pronounced "texan") respresents a payment between two ids on Manifold +// Shortened from "transaction" to distinguish from Firebase transactions (and save chars) +export type Txn = { + id: string + createdTime: number + + fromId: string + fromType: SourceType + + toId: string + toType: SourceType + + amount: number + token: 'M$' // | 'USD' | MarketOutcome + + category: 'CHARITY' // | 'BET' | 'TIP' + // Human-readable description + description?: string +} + +export type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' diff --git a/firestore.rules b/firestore.rules index 48214e3b..65783ba1 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1,5 +1,6 @@ rules_version = '2'; +// To pick the right project: `firebase projects:list`, then `firebase use ` // To deploy: `firebase deploy --only firestore:rules` service cloud.firestore { match /databases/{database}/documents { @@ -76,5 +77,9 @@ service cloud.firestore { allow create, update: if request.auth.uid == userId && request.resource.data.userId == userId; allow delete: if request.auth.uid == userId; } + + match /txns/{txnId} { + allow read; + } } } diff --git a/functions/src/backup-db.ts b/functions/src/backup-db.ts index bdafbf98..e840b71a 100644 --- a/functions/src/backup-db.ts +++ b/functions/src/backup-db.ts @@ -46,6 +46,7 @@ export const backupDb = functions.pubsub 'comments', 'followers', 'answers', + 'txns', ], }) .then((responses) => { diff --git a/functions/src/index.ts b/functions/src/index.ts index 19a4a054..f8aa50e3 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -3,6 +3,7 @@ import * as admin from 'firebase-admin' admin.initializeApp() // export * from './keep-awake' +export * from './transact' export * from './place-bet' export * from './resolve-market' export * from './stripe' diff --git a/functions/src/transact.ts b/functions/src/transact.ts new file mode 100644 index 00000000..04e58568 --- /dev/null +++ b/functions/src/transact.ts @@ -0,0 +1,82 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { User } from '../../common/user' +import { Txn } from '../../common/txn' +import { removeUndefinedProps } from '../../common/util/object' + +export const transact = functions + .runWith({ minInstances: 1 }) + .https.onCall(async (data: Omit, context) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + const { amount, fromType, fromId, toId, toType, description } = data + + if (fromType !== 'USER') + return { + status: 'error', + message: "From type is only implemented for type 'user'.", + } + + if (fromId !== userId) + return { + status: 'error', + message: 'Must be authenticated with userId equal to specified fromId.', + } + + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) + return { status: 'error', message: 'Invalid amount' } + + // Run as transaction to prevent race conditions. + return await firestore.runTransaction(async (transaction) => { + const fromDoc = firestore.doc(`users/${userId}`) + const fromSnap = await transaction.get(fromDoc) + if (!fromSnap.exists) { + return { status: 'error', message: 'User not found' } + } + const fromUser = fromSnap.data() as User + + if (fromUser.balance < amount) { + return { + status: 'error', + message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `, + } + } + + if (toType === 'USER') { + const toDoc = firestore.doc(`users/${toId}`) + const toSnap = await transaction.get(toDoc) + if (!toSnap.exists) { + return { status: 'error', message: 'User not found' } + } + const toUser = toSnap.data() as User + transaction.update(toDoc, { balance: toUser.balance + amount }) + } + + const newTxnDoc = firestore.collection(`txns/`).doc() + + const txn: Txn = removeUndefinedProps({ + id: newTxnDoc.id, + createdTime: Date.now(), + + fromId, + fromType, + toId, + toType, + + amount, + // TODO: Unhardcode once we have non-donation txns + token: 'M$', + category: 'CHARITY', + description, + }) + + transaction.create(newTxnDoc, txn) + transaction.update(fromDoc, { balance: fromUser.balance - amount }) + + return { status: 'success', txn } + }) + }) + +const firestore = admin.firestore() diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx new file mode 100644 index 00000000..e50a9fe7 --- /dev/null +++ b/web/components/charity/charity-card.tsx @@ -0,0 +1,34 @@ +import _ from 'lodash' +import Link from 'next/link' +import { Charity } from '../../../common/charity' +import { useCharityTxns } from '../../hooks/use-charity-txns' +import { Row } from '../layout/row' + +export function CharityCard(props: { charity: Charity }) { + const { name, slug, photo, preview, id } = props.charity + + const txns = useCharityTxns(id) + const raised = _.sumBy(txns, (txn) => txn.amount) + + return ( + +
+
+ {photo ? ( + + ) : ( +
+ )} +
+
+

{name}

+
{preview}
+ + ${Math.floor((raised ?? 0) / 100)} + raised + +
+
+ + ) +} diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index fadff13c..a9cb49f0 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -3,8 +3,8 @@ import Link from 'next/link' import { HomeIcon, MenuAlt3Icon, + PresentationChartLineIcon, SearchIcon, - TableIcon, XIcon, } from '@heroicons/react/outline' import { Transition, Dialog } from '@headlessui/react' @@ -39,7 +39,10 @@ export function BottomNavBar() { {user !== null && ( - diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 576cea87..e3145498 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -5,9 +5,10 @@ import { SearchIcon, ChatIcon, BookOpenIcon, - TableIcon, DotsHorizontalIcon, CashIcon, + HeartIcon, + PresentationChartLineIcon, } from '@heroicons/react/outline' import clsx from 'clsx' import _ from 'lodash' @@ -24,7 +25,8 @@ import { useHasCreatedContractToday } from '../../hooks/use-has-created-contract const navigation = [ { name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Explore', href: '/markets', icon: SearchIcon }, - { name: 'Portfolio', href: '/portfolio', icon: TableIcon }, + { name: 'Portfolio', href: '/portfolio', icon: PresentationChartLineIcon }, + { name: 'Charity', href: '/charity', icon: HeartIcon }, ] const signedOutNavigation = [ @@ -34,6 +36,7 @@ const signedOutNavigation = [ ] const signedOutMobileNavigation = [ + { name: 'Charity', href: '/charity', icon: HeartIcon }, { name: 'Leaderboards', href: '/leaderboards', icon: CakeIcon }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon }, { name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon }, diff --git a/web/hooks/use-charity-txns.ts b/web/hooks/use-charity-txns.ts new file mode 100644 index 00000000..5636e720 --- /dev/null +++ b/web/hooks/use-charity-txns.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react' +import { Txn } from '../../common/txn' +import { listenForCharityTxns } from '../lib/firebase/txns' + +export const useCharityTxns = (charityId: string) => { + const [txns, setTxns] = useState([]) + + useEffect(() => { + return listenForCharityTxns(charityId, setTxns) + }, [charityId]) + + return txns +} diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 1c5522e7..a71c2752 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -1,5 +1,6 @@ import { httpsCallable } from 'firebase/functions' import { Fold } from '../../../common/fold' +import { Txn } from '../../../common/txn' import { User } from '../../../common/user' import { randomString } from '../../../common/util/random' import './init' @@ -15,6 +16,11 @@ export const createFold = cloudFunction< { status: 'error' | 'success'; message?: string; fold?: Fold } >('createFold') +export const transact = cloudFunction< + Omit, + { status: 'error' | 'success'; message?: string; txn?: Txn } +>('transact') + export const placeBet = cloudFunction('placeBet') export const sellBet = cloudFunction('sellBet') diff --git a/web/lib/firebase/txns.ts b/web/lib/firebase/txns.ts new file mode 100644 index 00000000..8f9a6843 --- /dev/null +++ b/web/lib/firebase/txns.ts @@ -0,0 +1,23 @@ +import { collection, query, where, orderBy } from 'firebase/firestore' +import _ from 'lodash' +import { Txn } from '../../../common/txn' + +import { db } from './init' +import { listenForValues } from './utils' + +const txnCollection = collection(db, 'txns') + +const getCharityQuery = (charityId: string) => + query( + txnCollection, + where('toType', '==', 'CHARITY'), + where('toId', '==', charityId), + orderBy('createdTime', 'desc') + ) + +export function listenForCharityTxns( + charityId: string, + setTxns: (txns: Txn[]) => void +) { + return listenForValues(getCharityQuery(charityId), setTxns) +} diff --git a/web/package.json b/web/package.json index 84077a19..0e770f96 100644 --- a/web/package.json +++ b/web/package.json @@ -36,6 +36,7 @@ }, "devDependencies": { "@tailwindcss/forms": "0.4.0", + "@tailwindcss/line-clamp": "^0.3.1", "@tailwindcss/typography": "^0.5.1", "@types/lodash": "4.14.178", "@types/node": "16.11.11", diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx new file mode 100644 index 00000000..e358f4e1 --- /dev/null +++ b/web/pages/charity/[charitySlug].tsx @@ -0,0 +1,212 @@ +import _ from 'lodash' +import clsx from 'clsx' +import { useEffect, useRef, useState } from 'react' +import { Col } from '../../components/layout/col' +import { Row } from '../../components/layout/row' +import { Page } from '../../components/page' +import { Title } from '../../components/title' +import { BuyAmountInput } from '../../components/amount-input' +import { Spacer } from '../../components/layout/spacer' +import { User } from '../../../common/user' +import { useUser } from '../../hooks/use-user' +import { Linkify } from '../../components/linkify' +import { transact } from '../../lib/firebase/api-call' +import { charities, Charity } from '../../../common/charity' +import { useRouter } from 'next/router' +import Custom404 from '../404' +import { useCharityTxns } from '../../hooks/use-charity-txns' + +const manaToUSD = (mana: number) => + (mana / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' }) + +export default function CharityPageWrapper() { + const router = useRouter() + const { charitySlug } = router.query as { charitySlug: string } + + const charity = charities.find((c) => c.slug === charitySlug?.toLowerCase()) + if (!router.isReady) return <> + if (!charity) { + return + } + return +} + +function CharityPage(props: { charity: Charity }) { + const { charity } = props + const { name, photo, description } = charity + + // TODO: why not just useUser inside Donation Box rather than passing in? + const user = useUser() + + const txns = useCharityTxns(charity.id) + const totalRaised = _.sumBy(txns, (txn) => txn.amount) + const fromYou = _.sumBy( + txns.filter((txn) => txn.fromId === user?.id), + (txn) => txn.amount + ) + const numSupporters = _.uniqBy(txns, (txn) => txn.fromId).length + + return ( + }> + + + + {/* TODO: donations over time chart */} + <Row className="justify-between"> + {photo && ( + <img + src={photo} + alt="" + className="w-40 rounded-2xl object-contain" + /> + )} + <Details + charity={charity} + totalRaised={totalRaised} + userDonated={fromYou} + numSupporters={numSupporters} + /> + </Row> + <h2 className="mt-7 mb-2 text-xl text-indigo-700">About</h2> + <Blurb text={description} /> + </Col> + </Col> + </Page> + ) +} + +function Blurb({ text }: { text: string }) { + const [open, setOpen] = useState(false) + + // Calculate whether the full blurb is already shown + const ref = useRef<HTMLDivElement>(null) + const [hideExpander, setHideExpander] = useState(false) + useEffect(() => { + if (ref.current) { + setHideExpander(ref.current.scrollHeight <= ref.current.clientHeight) + } + }, []) + + return ( + <> + <div + className={clsx(' text-gray-500', !open && 'line-clamp-5')} + ref={ref} + > + {text} + </div> + <button + onClick={() => setOpen(!open)} + className={clsx( + 'btn btn-link capitalize-none my-3 normal-case text-indigo-700', + hideExpander && 'hidden' + )} + > + {open ? 'Hide' : 'Read more'} + </button> + </> + ) +} + +function Details(props: { + charity: Charity + totalRaised: number + userDonated: number + numSupporters: number +}) { + const { charity, userDonated, numSupporters, totalRaised } = props + const { website } = charity + return ( + <Col className="gap-1 text-right"> + <div className="text-primary mb-2 text-4xl"> + {manaToUSD(totalRaised ?? 0)} raised + </div> + {userDonated > 0 && ( + <div className="text-primary text-xl"> + {manaToUSD(userDonated)} from you! + </div> + )} + {numSupporters > 0 && ( + <div className="text-gray-500">{numSupporters} supporters</div> + )} + <Linkify text={website} /> + </Col> + ) +} + +function DonationBox(props: { user?: User | null; charity: Charity }) { + const { user, charity } = props + const [amount, setAmount] = useState<number | undefined>() + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState<string | undefined>() + + const donateDisabled = isSubmitting || !amount || error + + const onSubmit: React.FormEventHandler = async (e) => { + if (!user || donateDisabled) return + + e.preventDefault() + setIsSubmitting(true) + setError(undefined) + await transact({ + amount, + fromId: user.id, + fromType: 'USER', + toId: charity.id, + toType: 'CHARITY', + token: 'M$', + category: 'CHARITY', + description: `${user.name} donated M$ ${amount} to ${charity.name}`, + }).catch((err) => console.log('Error', err)) + setIsSubmitting(false) + setAmount(undefined) + } + + return ( + <div className="rounded-lg bg-white py-6 px-8 shadow-lg"> + <div className="mb-6 text-2xl text-gray-700">Donate</div> + <form onSubmit={onSubmit}> + <label + className="mb-2 block text-sm text-gray-500" + htmlFor="donate-input" + > + Amount + </label> + <BuyAmountInput + inputClassName="w-full donate-input" + amount={amount} + onChange={setAmount} + error={error} + setError={setError} + /> + + <Col className="mt-3 w-full gap-3"> + <Row className="items-center justify-between text-sm"> + <span className="text-gray-500">Conversion</span> + <span> + {amount || 0} Mana + <span className="mx-2">→</span> + {manaToUSD(amount || 0)} + </span> + </Row> + {/* TODO: matching pool */} + </Col> + + <Spacer h={8} /> + + {user && ( + <button + type="submit" + className={clsx( + 'btn w-full', + donateDisabled ? 'btn-disabled' : 'btn-primary', + isSubmitting && 'loading' + )} + > + Donate + </button> + )} + </form> + </div> + ) +} diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx new file mode 100644 index 00000000..c2b5a270 --- /dev/null +++ b/web/pages/charity/index.tsx @@ -0,0 +1,64 @@ +import _ from 'lodash' +import { useState, useMemo } from 'react' +import { charities as charityList } from '../../../common/charity' +import { CharityCard } from '../../components/charity/charity-card' +import { Col } from '../../components/layout/col' +import { Page } from '../../components/page' +import { Title } from '../../components/title' + +// TODO: Fetch amount raised. +const charities = charityList.map((charity) => ({ + ...charity, + raised: 4001, +})) + +export default function Charity() { + const [query, setQuery] = useState('') + const debouncedQuery = _.debounce(setQuery, 50) + + const filterCharities = useMemo( + () => charities.filter((charity) => charity.name.includes(query)), + [query] + ) + + return ( + <Page> + <Col className="w-full items-center rounded px-4 py-6 sm:px-8 xl:w-[125%]"> + <Col className="max-w-xl gap-2"> + <Title className="!mt-0" text="Donate your M$ to charity!" /> + <div className="mb-6 text-gray-500"> + Throughout the month of May, every M$ 100 you contribute turns into + $1 USD to your chosen charity. We'll cover all processing fees! + </div> + + <input + type="text" + onChange={(e) => debouncedQuery(e.target.value)} + placeholder="Search charities" + className="input input-bordered mb-6 w-full" + /> + </Col> + <div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 lg:max-w-full lg:grid-cols-2 xl:grid-cols-3"> + {filterCharities.map((charity) => ( + <div key={charity.name}> + <CharityCard charity={charity} /> + </div> + ))} + </div> + {filterCharities.length === 0 && ( + <div className="text-center text-gray-500"> + No charities match your search :( + </div> + )} + + <div className="mt-10 italic text-gray-500"> + Note: Manifold is not affiliated with any of these charities, other + than being fans of their work. + <br /> + As Manifold is a for-profit entity, your contributions will not be tax + deductible. + </div> + </Col> + </Page> + ) +} diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 5a87e424..31c0c533 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -22,6 +22,7 @@ module.exports = { plugins: [ require('@tailwindcss/forms'), require('@tailwindcss/typography'), + require('@tailwindcss/line-clamp'), require('daisyui'), ], daisyui: { diff --git a/yarn.lock b/yarn.lock index 852ccf89..dfcb2a69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -906,6 +906,11 @@ dependencies: mini-svg-data-uri "^1.2.3" +"@tailwindcss/line-clamp@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@tailwindcss/line-clamp/-/line-clamp-0.3.1.tgz#4d8441b509b87ece84e94f28a4aa9998413ab849" + integrity sha512-pNr0T8LAc3TUx/gxCfQZRe9NB2dPEo/cedPHzUGIPxqDMhgjwNm6jYxww4W5l0zAsAddxr+XfZcqttGiFDgrGg== + "@tailwindcss/typography@^0.5.1": version "0.5.1" resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.1.tgz#486248a9426501f11a9b0295f7cfc0eb29659c46" From 78e8927de49afc1f9aac8ac11b2c19afb60c3f15 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Fri, 29 Apr 2022 19:38:31 -0400 Subject: [PATCH 02/31] Image preview: truncate to 100 chars, show avatar url (#111) * Truncate image preview to 120 chars * Try 100 chars instead * Pass along creatorAvatarUrl Hoping nothing breaks if the avatarUrl is empty * Thread through avatarUrl all the way * Fix typescript --- og-image/api/_lib/template.ts | 7 +++++-- web/components/SEO.tsx | 8 ++++++-- web/pages/[username]/[contractSlug].tsx | 15 +++++++++++---- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index 73105f6b..00d47394 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -85,7 +85,6 @@ export function getHtml(parsedReq: ParsedRequest) { const { theme, fontSize, - question, probability, metadata, @@ -93,6 +92,10 @@ export function getHtml(parsedReq: ParsedRequest) { creatorUsername, creatorAvatarUrl, } = parsedReq + const MAX_QUESTION_CHARS = 100 + const truncatedQuestion = question.length > MAX_QUESTION_CHARS + ? question.slice(0, MAX_QUESTION_CHARS) + '...' + : question const hideAvatar = creatorAvatarUrl ? '' : 'hidden' return `<!DOCTYPE html> <html> @@ -141,7 +144,7 @@ export function getHtml(parsedReq: ParsedRequest) { <div class="flex flex-row justify-between gap-12 pt-36"> <div class="text-indigo-700 text-6xl leading-tight"> - ${question} + ${truncatedQuestion} </div> <div class="flex flex-col text-primary"> <div class="text-8xl">${probability}</div> diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index 84ba850c..8987d671 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -6,8 +6,7 @@ export type OgCardProps = { metadata: string creatorName: string creatorUsername: string - // TODO: Store creator avatar url in each contract, then enable this - // creatorAvatarUrl: string + creatorAvatarUrl?: string } function buildCardUrl(props: OgCardProps) { @@ -15,6 +14,10 @@ function buildCardUrl(props: OgCardProps) { props.probability === undefined ? '' : `&probability=${encodeURIComponent(props.probability ?? '')}` + const creatorAvatarUrlParam = + props.creatorAvatarUrl === undefined + ? '' + : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` // URL encode each of the props, then add them as query params return ( @@ -23,6 +26,7 @@ function buildCardUrl(props: OgCardProps) { probabilityParam + `&metadata=${encodeURIComponent(props.metadata)}` + `&creatorName=${encodeURIComponent(props.creatorName)}` + + creatorAvatarUrlParam + `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` ) } diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 29786703..6fa84a7f 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -324,8 +324,14 @@ function ContractTopTrades(props: { } const getOpenGraphProps = (contract: Contract) => { - const { resolution, question, creatorName, creatorUsername, outcomeType } = - contract + const { + resolution, + question, + creatorName, + creatorUsername, + outcomeType, + creatorAvatarUrl, + } = contract const probPercent = outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined @@ -339,8 +345,9 @@ const getOpenGraphProps = (contract: Contract) => { question, probability: probPercent, metadata: contractTextDetails(contract), - creatorName: creatorName, - creatorUsername: creatorUsername, + creatorName, + creatorUsername, + creatorAvatarUrl, description, } } From 3bb4111445b4a941b4ec99c7ab6e974921b9a059 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 29 Apr 2022 23:55:32 -0400 Subject: [PATCH 03/31] Make charity cards extend same length in row. Tweak image padding --- web/components/charity/charity-card.tsx | 6 +++--- web/pages/charity/index.tsx | 5 +---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx index e50a9fe7..d86afde5 100644 --- a/web/components/charity/charity-card.tsx +++ b/web/components/charity/charity-card.tsx @@ -12,8 +12,8 @@ export function CharityCard(props: { charity: Charity }) { return ( <Link href={`/charity/${slug}`} passHref> - <div className="card card-compact transition:shadow cursor-pointer border-2 bg-white hover:shadow-md"> - <figure className="h-32"> + <div className="card card-compact transition:shadow flex-1 cursor-pointer border-2 bg-white hover:shadow-md"> + <figure className="h-32 px-4 pt-4"> {photo ? ( <img className="h-full w-full object-contain" src={photo} alt="" /> ) : ( @@ -23,7 +23,7 @@ export function CharityCard(props: { charity: Charity }) { <div className="card-body"> <h3 className="card-title line-clamp-3">{name}</h3> <div className="line-clamp-4 text-sm">{preview}</div> - <Row className="text-primary mt-4 items-end justify-center gap-1"> + <Row className="text-primary mt-4 flex-1 items-end justify-center gap-2"> <span className="text-3xl">${Math.floor((raised ?? 0) / 100)}</span> <span>raised</span> </Row> diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index c2b5a270..f481eaa3 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -6,7 +6,6 @@ import { Col } from '../../components/layout/col' import { Page } from '../../components/page' import { Title } from '../../components/title' -// TODO: Fetch amount raised. const charities = charityList.map((charity) => ({ ...charity, raised: 4001, @@ -40,9 +39,7 @@ export default function Charity() { </Col> <div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 lg:max-w-full lg:grid-cols-2 xl:grid-cols-3"> {filterCharities.map((charity) => ( - <div key={charity.name}> - <CharityCard charity={charity} /> - </div> + <CharityCard charity={charity} key={charity.name} /> ))} </div> {filterCharities.length === 0 && ( From 17b5345d824d697d7e7a19294b44bde06e3d5c3f Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 30 Apr 2022 04:44:11 -0700 Subject: [PATCH 04/31] Fix HTTP charity logo links (#113) --- common/charity.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/charity.ts b/common/charity.ts index a4cfd551..a45e51c3 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -78,7 +78,7 @@ export const charities: Charity[] = [ website: 'https://www.malariaconsortium.org/', ein: '98-0627052', photo: - 'http://www.malariaconsortium.org/website-2013/images_template/malaria_consortium_logo.png', + 'https://www.malariaconsortium.org/website-2013/images_template/malaria_consortium_logo.png', preview: 'We specialise in the prevention, control and treatment of malaria and other communicable diseases.', description: @@ -110,7 +110,7 @@ export const charities: Charity[] = [ website: 'https://wikimediafoundation.org/', ein: '20-0049703', photo: - 'http://2.bp.blogspot.com/-jVseU39DW0s/VjmXVMOEEEI/AAAAAAAACK8/dwUP6sLqy-Q/s1600/wikimedia.png', + 'https://2.bp.blogspot.com/-jVseU39DW0s/VjmXVMOEEEI/AAAAAAAACK8/dwUP6sLqy-Q/s1600/wikimedia.png', preview: 'We help everyone share in the sum of all knowledge.', description: 'We are the people who keep knowledge free. There is an amazing community of people around the world that makes great projects like Wikipedia. We help them do that work. We take care of the technical infrastructure, the legal challenges, and the growing pains.', @@ -120,7 +120,7 @@ export const charities: Charity[] = [ website: 'https://www.rainforesttrust.org/', ein: '13-3500609', photo: - 'http://ww1.prweb.com/prfiles/2019/05/29/16344590/Rrainforest%20Trust%20new%20logo%20tall-1%20copy.png', + 'https://ww1.prweb.com/prfiles/2019/05/29/16344590/Rrainforest%20Trust%20new%20logo%20tall-1%20copy.png', preview: 'Rainforest Trust saves endangered wildlife and protects our planet by creating rainforest reserves through partnerships, community engagement and donor support.', description: From a6de0ec69512a633effdf0c6e0f4fbb7728fcaf0 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 08:06:24 -0400 Subject: [PATCH 05/31] Add missing charity logos --- common/charity.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/charity.ts b/common/charity.ts index a45e51c3..4f437bab 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -160,6 +160,7 @@ export const charities: Charity[] = [ { name: 'UNICEF USA', website: 'https://www.unicefusa.org/', + photo: 'https://www.unicefusa.org/sites/default/files/UNICEFUSA_DIG_C.svg', ein: '13-1760110', preview: "UNICEF USA helps save and protect the world's most vulnerable children.", @@ -267,6 +268,8 @@ export const charities: Charity[] = [ { name: 'ALLFED', website: 'https://allfed.info/', + photo: + 'https://images1.the-dots.com/1860424/allfed-logo-1.png?p=projectImageFullJpg', ein: '27-6601178', preview: 'Feeding everyone no matter what.', description: From bd98e8810e65e767cf0728c9dfa94cd2dca91927 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 08:09:20 -0400 Subject: [PATCH 06/31] Disable "Read more..." for now --- web/pages/charity/[charitySlug].tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index e358f4e1..ff548dc5 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -76,7 +76,8 @@ function CharityPage(props: { charity: Charity }) { } function Blurb({ text }: { text: string }) { - const [open, setOpen] = useState(false) + // Default to open for now (aka don't actually hide any text yet.) + const [open, setOpen] = useState(true) // Calculate whether the full blurb is already shown const ref = useRef<HTMLDivElement>(null) From c51aa0b6b45e2ab274df6efadd09ed2f5217b38d Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 08:13:38 -0400 Subject: [PATCH 07/31] Simpify Donate panel --- web/pages/charity/[charitySlug].tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index ff548dc5..af3382e3 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -165,7 +165,7 @@ function DonationBox(props: { user?: User | null; charity: Charity }) { return ( <div className="rounded-lg bg-white py-6 px-8 shadow-lg"> - <div className="mb-6 text-2xl text-gray-700">Donate</div> + <Title text="Donate" className="!mt-0" /> <form onSubmit={onSubmit}> <label className="mb-2 block text-sm text-gray-500" @@ -183,12 +183,8 @@ function DonationBox(props: { user?: User | null; charity: Charity }) { <Col className="mt-3 w-full gap-3"> <Row className="items-center justify-between text-sm"> - <span className="text-gray-500">Conversion</span> - <span> - {amount || 0} Mana - <span className="mx-2">→</span> - {manaToUSD(amount || 0)} - </span> + <span className="text-gray-500">To {charity.name}</span> + <span>{manaToUSD(amount || 0)}</span> </Row> {/* TODO: matching pool */} </Col> From 774ba6fba6cf94e40baaa4eb808f39bac997b5e0 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 08:18:25 -0400 Subject: [PATCH 08/31] Show confetti on donate --- web/pages/charity/[charitySlug].tsx | 35 +++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index af3382e3..f4254293 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -15,6 +15,8 @@ import { charities, Charity } from '../../../common/charity' import { useRouter } from 'next/router' import Custom404 from '../404' import { useCharityTxns } from '../../hooks/use-charity-txns' +import { useWindowSize } from '../../hooks/use-window-size' +import Confetti from 'react-confetti' const manaToUSD = (mana: number) => (mana / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' }) @@ -46,8 +48,28 @@ function CharityPage(props: { charity: Charity }) { ) const numSupporters = _.uniqBy(txns, (txn) => txn.fromId).length + const { width, height } = useWindowSize() + const [showConfetti, setShowConfetti] = useState(false) + return ( - <Page rightSidebar={<DonationBox user={user} charity={charity} />}> + <Page + rightSidebar={ + <DonationBox + user={user} + charity={charity} + setShowConfetti={setShowConfetti} + /> + } + > + {showConfetti && ( + <Confetti + width={width ? width : 500} + height={height ? height : 500} + recycle={false} + numberOfPieces={300} + /> + )} + <Col className="mx-1 w-full items-center sm:px-0"> <Col className="max-w-2xl rounded bg-white px-8 py-6"> <Title className="!mt-0" text={name} /> @@ -135,8 +157,12 @@ function Details(props: { ) } -function DonationBox(props: { user?: User | null; charity: Charity }) { - const { user, charity } = props +function DonationBox(props: { + user?: User | null + charity: Charity + setShowConfetti: (show: boolean) => void +}) { + const { user, charity, setShowConfetti } = props const [amount, setAmount] = useState<number | undefined>() const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState<string | undefined>() @@ -161,6 +187,7 @@ function DonationBox(props: { user?: User | null; charity: Charity }) { }).catch((err) => console.log('Error', err)) setIsSubmitting(false) setAmount(undefined) + setShowConfetti(true) } return ( @@ -183,7 +210,7 @@ function DonationBox(props: { user?: User | null; charity: Charity }) { <Col className="mt-3 w-full gap-3"> <Row className="items-center justify-between text-sm"> - <span className="text-gray-500">To {charity.name}</span> + <span className="text-gray-500">{charity.name} receives</span> <span>{manaToUSD(amount || 0)}</span> </Row> {/* TODO: matching pool */} From 5c03f1581af1da988f70f4f60e943c5a0a124816 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 08:42:25 -0400 Subject: [PATCH 09/31] Fix lowercase search, tweak copy --- web/pages/charity/index.tsx | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index f481eaa3..1c546952 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -16,7 +16,10 @@ export default function Charity() { const debouncedQuery = _.debounce(setQuery, 50) const filterCharities = useMemo( - () => charities.filter((charity) => charity.name.includes(query)), + () => + charities.filter((charity) => + charity.name.toLowerCase().includes(query.toLowerCase()) + ), [query] ) @@ -24,10 +27,10 @@ export default function Charity() { <Page> <Col className="w-full items-center rounded px-4 py-6 sm:px-8 xl:w-[125%]"> <Col className="max-w-xl gap-2"> - <Title className="!mt-0" text="Donate your M$ to charity!" /> + <Title className="!mt-0" text="Manifold for Good" /> <div className="mb-6 text-gray-500"> - Throughout the month of May, every M$ 100 you contribute turns into - $1 USD to your chosen charity. We'll cover all processing fees! + Donate your winnings to charity! Through the month of May, every M$ + 100 you contribute turns into $1 USD sent to your chosen charity. </div> <input @@ -44,16 +47,22 @@ export default function Charity() { </div> {filterCharities.length === 0 && ( <div className="text-center text-gray-500"> - No charities match your search :( + 😢 We couldn't find that charity... </div> )} - <div className="mt-10 italic text-gray-500"> - Note: Manifold is not affiliated with any of these charities, other - than being fans of their work. + <div className="mt-10 text-gray-500"> + Don't see your favorite charity? Recommend that we add it by emailing + <span className="text-indigo-500"> give@manifold.markets</span>~ <br /> - As Manifold is a for-profit entity, your contributions will not be tax - deductible. + <br /> + <span className="italic"> + Note: Manifold is not affiliated with any of these charities (other + than being fans of their work!) + <br /> + As Manifold is a for-profit entity, your contributions will not be + tax deductible. + </span> </div> </Col> </Page> From cc300c84e15f170ebfb5d0e827c288739c153e3c Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 08:52:24 -0400 Subject: [PATCH 10/31] Adjust totalDeposits on charity donation --- functions/src/transact.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/functions/src/transact.ts b/functions/src/transact.ts index 04e58568..79b5ccb8 100644 --- a/functions/src/transact.ts +++ b/functions/src/transact.ts @@ -51,7 +51,10 @@ export const transact = functions return { status: 'error', message: 'User not found' } } const toUser = toSnap.data() as User - transaction.update(toDoc, { balance: toUser.balance + amount }) + transaction.update(toDoc, { + balance: toUser.balance + amount, + totalDeposits: toUser.totalDeposits + amount, + }) } const newTxnDoc = firestore.collection(`txns/`).doc() @@ -73,7 +76,10 @@ export const transact = functions }) transaction.create(newTxnDoc, txn) - transaction.update(fromDoc, { balance: fromUser.balance - amount }) + transaction.update(fromDoc, { + balance: fromUser.balance - amount, + totalDeposits: fromUser.totalDeposits - amount, + }) return { status: 'success', txn } }) From d2218b5b8bf11248813c51675fd47aac6244eb11 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 09:45:24 -0400 Subject: [PATCH 11/31] give@ isn't set up, use info@ --- web/pages/charity/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 1c546952..3cbb3819 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -53,7 +53,7 @@ export default function Charity() { <div className="mt-10 text-gray-500"> Don't see your favorite charity? Recommend that we add it by emailing - <span className="text-indigo-500"> give@manifold.markets</span>~ + <span className="text-indigo-500"> info@manifold.markets</span>~ <br /> <br /> <span className="italic"> From a4c722550a8bce11bdf10ba8d4f5ed593e843984 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 30 Apr 2022 10:07:32 -0400 Subject: [PATCH 12/31] Don't truncate comments for contract page --- web/components/feed/activity-items.ts | 2 +- web/components/feed/feed-items.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts index 7879b637..40dd2338 100644 --- a/web/components/feed/activity-items.ts +++ b/web/components/feed/activity-items.ts @@ -324,7 +324,7 @@ function getCommentsWithPositions( contract: contract, comment, betsBySameUser: bets.length === 0 ? [] : betsByUserId[comment.userId] ?? [], - truncate: true, + truncate: false, hideOutcome: false, smallAvatar: false, })) diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index 584907d9..aa501bcd 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -9,7 +9,6 @@ import { UserIcon, UsersIcon, XIcon, - SparklesIcon, } from '@heroicons/react/solid' import clsx from 'clsx' import Textarea from 'react-expanding-textarea' From 04c42e78353b6a08d5a0f0de8707d6c09c74ba1b Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 10:19:59 -0400 Subject: [PATCH 13/31] Tweak copy ("You with" => "You have") --- web/components/feed/feed-items.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index aa501bcd..c447f666 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -180,7 +180,7 @@ export function FeedComment(props: { />{' '} {!matchedBet && userPosition > 0 && ( <> - {'with ' + userPositionMoney + ' '} + {'had ' + userPositionMoney + ' '} <> {' of '} <OutcomeLabel @@ -269,7 +269,7 @@ export function CommentInput(props: { <div className="text-sm text-gray-500"> {user && userPosition > 0 && ( <> - {'You with ' + userPositionMoney + ' '} + {'You have ' + userPositionMoney + ' '} <> {' of '} <OutcomeLabel From ccd0e4273455fec0a9883467adff1629b5920a40 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 11:19:20 -0400 Subject: [PATCH 14/31] Revert "give@ isn't set up, use info@" This reverts commit d2218b5b8bf11248813c51675fd47aac6244eb11. --- web/pages/charity/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 3cbb3819..1c546952 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -53,7 +53,7 @@ export default function Charity() { <div className="mt-10 text-gray-500"> Don't see your favorite charity? Recommend that we add it by emailing - <span className="text-indigo-500"> info@manifold.markets</span>~ + <span className="text-indigo-500"> give@manifold.markets</span>~ <br /> <br /> <span className="italic"> From f6d440989920d7212b4b8c900a061b256b7764d8 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 30 Apr 2022 12:24:24 -0400 Subject: [PATCH 15/31] return liquidity pool after resolution according to true pool weight --- common/calculate-cpmm.ts | 32 ++++++++++++++++++++++++++++++++ common/payouts-fixed.ts | 21 +++++++++++---------- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index 88caf0d0..d36d2f21 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -2,6 +2,8 @@ import * as _ from 'lodash' import { Binary, CPMM, FullContract } from './contract' import { CREATOR_FEE, Fees, LIQUIDITY_FEE, noFees, PLATFORM_FEE } from './fees' +import { LiquidityProvision } from './liquidity-provision' +import { addObjects } from './util/object' export function getCpmmProbability( pool: { [outcome: string]: number }, @@ -258,6 +260,36 @@ export function addCpmmLiquidity( return { newPool, liquidity, newP } } +export function getCpmmLiquidityPoolWeights( + contract: FullContract<CPMM, Binary>, + liquidities: LiquidityProvision[] +) { + const { p } = contract + + const liquidityShares = liquidities.map((l) => { + const oldLiquidity = getCpmmLiquidity(l.pool, p) + + const newPool = addObjects(l.pool, { YES: l.amount, NO: l.amount }) + const newLiquidity = getCpmmLiquidity(newPool, p) + + const liquidity = newLiquidity - oldLiquidity + return liquidity + }) + + const shareSum = _.sum(liquidityShares) + + const weights = liquidityShares.map((s, i) => ({ + weight: s / shareSum, + providerId: liquidities[i].userId, + })) + + const userWeights = _.groupBy(weights, (w) => w.providerId) + const totalUserWeights = _.mapValues(userWeights, (userWeight) => + _.sumBy(userWeight, (w) => w.weight) + ) + return totalUserWeights +} + // export function removeCpmmLiquidity( // contract: FullContract<CPMM, Binary>, // liquidity: number diff --git a/common/payouts-fixed.ts b/common/payouts-fixed.ts index 3965c352..d06a8411 100644 --- a/common/payouts-fixed.ts +++ b/common/payouts-fixed.ts @@ -2,6 +2,7 @@ import * as _ from 'lodash' import { Bet } from './bet' import { getProbability } from './calculate' +import { getCpmmLiquidityPoolWeights } from './calculate-cpmm' import { Binary, CPMM, FixedPayouts, FullContract } from './contract' import { LiquidityProvision } from './liquidity-provision' @@ -60,14 +61,14 @@ export const getLiquidityPoolPayouts = ( outcome: string, liquidities: LiquidityProvision[] ) => { - const providedLiquidity = _.sumBy(liquidities, (lp) => lp.liquidity) - const { pool } = contract const finalPool = pool[outcome] - return liquidities.map((lp) => ({ - userId: lp.userId, - payout: (lp.liquidity / providedLiquidity) * finalPool, + const weights = getCpmmLiquidityPoolWeights(contract, liquidities) + + return Object.entries(weights).map(([providerId, weight]) => ({ + userId: providerId, + payout: weight * finalPool, })) } @@ -111,13 +112,13 @@ export const getLiquidityPoolProbPayouts = ( p: number, liquidities: LiquidityProvision[] ) => { - const providedLiquidity = _.sumBy(liquidities, (lp) => lp.liquidity) - const { pool } = contract const finalPool = p * pool.YES + (1 - p) * pool.NO - return liquidities.map((lp) => ({ - userId: lp.userId, - payout: (lp.liquidity / providedLiquidity) * finalPool, + const weights = getCpmmLiquidityPoolWeights(contract, liquidities) + + return Object.entries(weights).map(([providerId, weight]) => ({ + userId: providerId, + payout: weight * finalPool, })) } From f5e5af0b7ac455e65a165e58a814cf5ac719661a Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 13:05:43 -0400 Subject: [PATCH 16/31] Revert "bound initial probability to [0.1, 0.9]" This reverts commit da153ceea92a3d20914fff26f77f5c9c8204bed9. --- web/components/probability-selector.tsx | 22 +++++----------------- web/pages/create.tsx | 7 +------ 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/web/components/probability-selector.tsx b/web/components/probability-selector.tsx index 74dab3c6..2fc03787 100644 --- a/web/components/probability-selector.tsx +++ b/web/components/probability-selector.tsx @@ -4,11 +4,8 @@ export function ProbabilitySelector(props: { probabilityInt: number setProbabilityInt: (p: number) => void isSubmitting?: boolean - minProb?: number - maxProb?: number }) { - const { probabilityInt, setProbabilityInt, isSubmitting, minProb, maxProb } = - props + const { probabilityInt, setProbabilityInt, isSubmitting } = props return ( <Row className="items-center gap-2"> @@ -18,28 +15,19 @@ export function ProbabilitySelector(props: { value={probabilityInt} className="input input-bordered input-md text-lg" disabled={isSubmitting} - min={minProb ?? 1} - max={maxProb ?? 99} + min={1} + max={99} onChange={(e) => setProbabilityInt(parseInt(e.target.value.substring(0, 2))) } - onBlur={() => - setProbabilityInt( - maxProb && probabilityInt > maxProb - ? maxProb - : minProb && probabilityInt < minProb - ? minProb - : probabilityInt - ) - } /> <span>%</span> </label> <input type="range" className="range range-primary" - min={minProb ?? 1} - max={maxProb ?? 99} + min={1} + max={99} value={probabilityInt} onChange={(e) => setProbabilityInt(parseInt(e.target.value))} /> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index ab614167..9f4dae84 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -110,14 +110,11 @@ export function NewContract(props: { question: string; tag?: string }) { setIsSubmitting(true) - const boundedProb = - initialProb > 90 ? 90 : initialProb < 10 ? 10 : initialProb - const result: any = await createContract({ question, outcomeType, description, - initialProb: boundedProb, + initialProb, ante, closeTime, tags, @@ -179,8 +176,6 @@ export function NewContract(props: { question: string; tag?: string }) { <ProbabilitySelector probabilityInt={initialProb} setProbabilityInt={setInitialProb} - minProb={10} - maxProb={90} /> </div> )} From 1c7232f31e7cc5753bfdc916c012851481484278 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 15:26:07 -0400 Subject: [PATCH 17/31] Add 1Day Sooner & QURI as supported charities --- common/charity.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/common/charity.ts b/common/charity.ts index 4f437bab..88f5974d 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -3,13 +3,40 @@ export interface Charity { slug: string name: string website: string - ein: string + ein?: string photo?: string preview: string description: string + tags?: CharityTag[] } +type CharityTag = 'Featured' // | 'Health' | 'Poverty' | 'X-Risk' | 'Animal Welfare' | 'Policy' + export const charities: Charity[] = [ + { + name: '1Day Sooner', + website: 'https://www.1daysooner.org/', + preview: + 'Accelerating the development of each additional safe and effective vaccine by even a couple of days via COVID-19 human challenge trials could save thousands of lives.', + photo: + 'https://images.squarespace-cdn.com/content/v1/5f5f8496d1d7713486b6075a/666cbb5a-5335-4323-b9ea-b764edc826e1/OFFICIAL+1Day+Sooner+Logo.png', + description: `1Day Sooner is a non-profit that advocates on behalf of COVID-19 challenge trial volunteers. + After a vaccine candidate is created in a lab, it is developed through a combination of pre-clinical evaluation and three phases of clinical trials that test its safety and efficacy. In traditional Phase III trials, participants receive the vaccine candidate or a placebo/active comparator, and efficacy is judged by comparing the prevalence of infection in the vaccine group and the placebo/comparator group, to test the hypothesis that significantly fewer participants in the vaccine group get infected. In these traditional trials, after receiving the treatment, participants return to their homes and their normal daily lives so as to test the treatment under real world conditions. Since only a small proportion of these participants may encounter the disease, it may take a large number of participants and a good deal of time for these trials to reveal differences between the vaccine and placebo groups. + In a human challenge trial (HCT), willing participants would receive the vaccine candidate or placebo and, after some time for the vaccine to take effect, be deliberately exposed to live coronavirus. Since exposure to the virus is guaranteed in HCTs, it may be possible to judge a vaccine candidate’s efficacy more quickly and with far fewer participants than a standard Phase III trial. While HCT efficacy results do not traditionally provide sufficient basis for licensure on their own, they could allow us to (1) more quickly weed out disappointing vaccine candidates or (2) promote the development of promising candidates in conjunction with traditional Phase III studies. + In addition, by gathering detailed data on the process of infection and vaccine protection in a clinical setting, researchers could learn information that proves extremely useful for broader vaccine and therapeutic development efforts. Altogether, there are scenarios in which the speed of HCTs and the richness of the data they provide accelerate the development of an effective and broadly accessible COVID-19 vaccine, with thousands of lives spared (depending on the pandemic’s long-term trajectory).`, + tags: ['Featured'] as CharityTag[], + }, + { + name: 'QURI', + website: 'https://quantifieduncertainty.org/', + preview: + 'A new initiative to advance forecasting and epistemics with the aim of improving the long-term future of humanity.', + photo: + 'https://quantifieduncertainty.org/_next/image?url=https%3A%2F%2Fsuper-static-assets.s3.amazonaws.com%2F09bb1362-5e3f-4724-8ffd-f3235f67356f%2Fimages%2F6151ac3e-aed7-44c7-9827-399fe6e9222b.png&w=1920&q=80', + description: `QURI researches systematic practices to specify and estimate the most important parameters for the most important or scalable decisions. Research areas include forecasting, epistemics, evaluations, ontology, and estimation. We emphasize technological solutions that can heavily scale in the next 5 to 30 years. + We believe that humanity’s success in the next few hundred years will lie intensely on its ability to coordinate and make good decisions. If important governmental and philanthropic bodies become significantly more effective, this will make society far more resilient to many kinds of challenges ahead.`, + tags: ['Featured'] as CharityTag[], + }, { name: 'Faunalytics', website: 'https://faunalytics.org/', From 46bf09f18274557f327e45d6dea4e2221cfe9529 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 15:47:47 -0400 Subject: [PATCH 18/31] Feature 1Day Sooner & QURI --- common/charity.ts | 64 ++++++++++++------------- web/components/charity/charity-card.tsx | 27 +++++++++-- 2 files changed, 54 insertions(+), 37 deletions(-) diff --git a/common/charity.ts b/common/charity.ts index 88f5974d..9c9045b2 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -37,38 +37,6 @@ export const charities: Charity[] = [ We believe that humanity’s success in the next few hundred years will lie intensely on its ability to coordinate and make good decisions. If important governmental and philanthropic bodies become significantly more effective, this will make society far more resilient to many kinds of challenges ahead.`, tags: ['Featured'] as CharityTag[], }, - { - name: 'Faunalytics', - website: 'https://faunalytics.org/', - ein: '01-0686889', - photo: - 'https://animalcharityevaluators.org/wp-content/uploads/2016/08/logo-faunalytics2400x2400-200x200@2x.jpg', - preview: - 'Faunalytics conducts research and shares knowledge to help advocates help animals effectively.', - description: - "Faunalytics' mission is to empower animal advocates with access to research, analysis, strategies, and messages that maximize their effectiveness to reduce animal suffering.\n Animals need you, and you need data. We conduct essential research, maintain an online research library, and directly support advocates and organizations in their work to save lives. The range of data we offer helps our movement understand how people think about and respond to advocacy, providing advocates with the best strategies to inspire change for animals. ", - }, - { - name: 'The Humane League', - website: 'https://thehumaneleague.org/', - ein: '04-3817491', - photo: - 'https://animalcharityevaluators.org/wp-content/uploads/2019/03/thl-mended-heart-logo@2x-200x200@2x.jpg', - preview: - 'We exist to end the abuse of animals raised for food by influencing the policies of the world’s biggest companies, demanding legislation, and empowering others to take action and leave animals off their plates', - description: - 'The Humane League (THL) currently operates in the U.S., Mexico, the U.K., and Japan, where they work to improve animal welfare standards through grassroots campaigns, movement building, veg*n advocacy, research, and advocacy training, as well as through corporate, media, and community outreach. They work to build the animal advocacy movement internationally through the Open Wing Alliance (OWA), a coalition founded by THL whose mission is to end the use of battery cages globally.', - }, - { - name: 'Wild Animal Initiative', - website: 'https://www.wildanimalinitiative.org/', - ein: '82-2281466', - photo: - 'https://animalcharityevaluators.org/wp-content/uploads/2020/11/WAI-logo_square-gray-on-teal-1-630x630.png', - preview: 'We want to make life better for wild animals.', - description: - 'Wild Animal Initiative (WAI) currently operates in the U.S., where they work to strengthen the animal advocacy movement through creating an academic field dedicated to wild animal welfare. They compile literature reviews, write theoretical and opinion articles, and publish research results on their website and/or in peer-reviewed journals. WAI focuses on identifying and sharing possible research avenues and connecting with more established fields. They also work with researchers from various academic and non-academic institutions to identify potential collaborators, and they recently launched a grant assistance program.', - }, { name: 'Give Directly', website: 'https://www.givedirectly.org/', @@ -111,6 +79,38 @@ export const charities: Charity[] = [ description: 'We are dedicated to ensuring our work is supported by strong evidence and remains grounded in the lessons we learn through implementation. We explore beyond current practice, to try out innovative ways – through research, implementation and policy development – to achieve effective and sustainable disease management and control.', }, + { + name: 'Faunalytics', + website: 'https://faunalytics.org/', + ein: '01-0686889', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2016/08/logo-faunalytics2400x2400-200x200@2x.jpg', + preview: + 'Faunalytics conducts research and shares knowledge to help advocates help animals effectively.', + description: + "Faunalytics' mission is to empower animal advocates with access to research, analysis, strategies, and messages that maximize their effectiveness to reduce animal suffering.\n Animals need you, and you need data. We conduct essential research, maintain an online research library, and directly support advocates and organizations in their work to save lives. The range of data we offer helps our movement understand how people think about and respond to advocacy, providing advocates with the best strategies to inspire change for animals. ", + }, + { + name: 'The Humane League', + website: 'https://thehumaneleague.org/', + ein: '04-3817491', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2019/03/thl-mended-heart-logo@2x-200x200@2x.jpg', + preview: + 'We exist to end the abuse of animals raised for food by influencing the policies of the world’s biggest companies, demanding legislation, and empowering others to take action and leave animals off their plates', + description: + 'The Humane League (THL) currently operates in the U.S., Mexico, the U.K., and Japan, where they work to improve animal welfare standards through grassroots campaigns, movement building, veg*n advocacy, research, and advocacy training, as well as through corporate, media, and community outreach. They work to build the animal advocacy movement internationally through the Open Wing Alliance (OWA), a coalition founded by THL whose mission is to end the use of battery cages globally.', + }, + { + name: 'Wild Animal Initiative', + website: 'https://www.wildanimalinitiative.org/', + ein: '82-2281466', + photo: + 'https://animalcharityevaluators.org/wp-content/uploads/2020/11/WAI-logo_square-gray-on-teal-1-630x630.png', + preview: 'We want to make life better for wild animals.', + description: + 'Wild Animal Initiative (WAI) currently operates in the U.S., where they work to strengthen the animal advocacy movement through creating an academic field dedicated to wild animal welfare. They compile literature reviews, write theoretical and opinion articles, and publish research results on their website and/or in peer-reviewed journals. WAI focuses on identifying and sharing possible research avenues and connecting with more established fields. They also work with researchers from various academic and non-academic institutions to identify potential collaborators, and they recently launched a grant assistance program.', + }, { name: 'New Incentives', website: 'https://www.newincentives.org/', diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx index d86afde5..d1763ca8 100644 --- a/web/components/charity/charity-card.tsx +++ b/web/components/charity/charity-card.tsx @@ -1,3 +1,4 @@ +import { StarIcon } from '@heroicons/react/solid' import _ from 'lodash' import Link from 'next/link' import { Charity } from '../../../common/charity' @@ -5,7 +6,7 @@ import { useCharityTxns } from '../../hooks/use-charity-txns' import { Row } from '../layout/row' export function CharityCard(props: { charity: Charity }) { - const { name, slug, photo, preview, id } = props.charity + const { name, slug, photo, preview, id, tags } = props.charity const txns = useCharityTxns(id) const raised = _.sumBy(txns, (txn) => txn.amount) @@ -13,6 +14,10 @@ export function CharityCard(props: { charity: Charity }) { return ( <Link href={`/charity/${slug}`} passHref> <div className="card card-compact transition:shadow flex-1 cursor-pointer border-2 bg-white hover:shadow-md"> + <Row className="mt-6"> + {tags?.includes('Featured') && <FeaturedBadge />} + </Row> + <figure className="h-32 px-4 pt-4"> {photo ? ( <img className="h-full w-full object-contain" src={photo} alt="" /> @@ -23,12 +28,24 @@ export function CharityCard(props: { charity: Charity }) { <div className="card-body"> <h3 className="card-title line-clamp-3">{name}</h3> <div className="line-clamp-4 text-sm">{preview}</div> - <Row className="text-primary mt-4 flex-1 items-end justify-center gap-2"> - <span className="text-3xl">${Math.floor((raised ?? 0) / 100)}</span> - <span>raised</span> - </Row> + {raised > 0 && ( + <Row className="text-primary mt-4 flex-1 items-end justify-center gap-2"> + <span className="text-3xl"> + ${Math.floor((raised ?? 0) / 100)} + </span> + <span>raised</span> + </Row> + )} </div> </div> </Link> ) } + +function FeaturedBadge() { + return ( + <span className="inline-flex items-center gap-1 bg-yellow-100 px-3 py-0.5 text-sm font-medium text-yellow-800"> + <StarIcon className="h-4 w-4" aria-hidden="true" /> Featured + </span> + ) +} From a3311bd5aa056378b4d043016186767ba67a7e5a Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 30 Apr 2022 16:27:19 -0400 Subject: [PATCH 19/31] embed total donations market on charity page --- web/pages/charity/index.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 1c546952..57008678 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -51,6 +51,15 @@ export default function Charity() { </div> )} + <iframe + width="560" + height="405" + src="https://manifold.markets/embed/ManifoldMarkets/total-donations-for-manifold-for-go" + title="Total donations for Manifold for Good this May (in USD)" + frameBorder="0" + className="m-10" + ></iframe> + <div className="mt-10 text-gray-500"> Don't see your favorite charity? Recommend that we add it by emailing <span className="text-indigo-500"> give@manifold.markets</span>~ From 731e5d5b7c981d91962598a5bb8e409b2a758281 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 30 Apr 2022 13:30:49 -0700 Subject: [PATCH 20/31] Apply permissive CORS headers to API (#115) * Take cors package as dependency * Apply permissive CORS headers to all API routes --- web/lib/api/cors.ts | 20 ++++++++++++++++++++ web/package.json | 1 + web/pages/api/v0/market/[id].ts | 2 ++ web/pages/api/v0/markets.ts | 2 ++ web/pages/api/v0/slug/[slug].ts | 2 ++ 5 files changed, 27 insertions(+) create mode 100644 web/lib/api/cors.ts diff --git a/web/lib/api/cors.ts b/web/lib/api/cors.ts new file mode 100644 index 00000000..976a0ffc --- /dev/null +++ b/web/lib/api/cors.ts @@ -0,0 +1,20 @@ +import Cors from 'cors' +import { NextApiRequest, NextApiResponse } from 'next' + +export function applyCorsHeaders( + req: NextApiRequest, + res: NextApiResponse, + params: object +) { + // This cors module is made as express.js middleware, so it's easier to promisify it for ourselves. + return new Promise((resolve, reject) => { + Cors(params)(req, res, (result) => { + if (result instanceof Error) { + return reject(result) + } + return resolve(result) + }) + }) +} + +export const CORS_UNRESTRICTED = {} diff --git a/web/package.json b/web/package.json index 0e770f96..d29a01b5 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,7 @@ "@nivo/core": "0.74.0", "@nivo/line": "0.74.0", "clsx": "1.1.1", + "cors": "^2.8.5", "daisyui": "1.16.4", "dayjs": "1.10.7", "firebase": "9.6.0", diff --git a/web/pages/api/v0/market/[id].ts b/web/pages/api/v0/market/[id].ts index c6dee26a..2faa9c57 100644 --- a/web/pages/api/v0/market/[id].ts +++ b/web/pages/api/v0/market/[id].ts @@ -3,11 +3,13 @@ import { Bet, listAllBets } from '../../../../lib/firebase/bets' import { listAllComments } from '../../../../lib/firebase/comments' import { getContractFromId } from '../../../../lib/firebase/contracts' import { FullMarket, ApiError, toLiteMarket } from '../_types' +import { applyCorsHeaders, CORS_UNRESTRICTED } from '../../../../lib/api/cors' export default async function handler( req: NextApiRequest, res: NextApiResponse<FullMarket | ApiError> ) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) const { id } = req.query const contractId = id as string diff --git a/web/pages/api/v0/markets.ts b/web/pages/api/v0/markets.ts index 5bda56f6..a27fd27a 100644 --- a/web/pages/api/v0/markets.ts +++ b/web/pages/api/v0/markets.ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { listAllContracts } from '../../../lib/firebase/contracts' import { toLiteMarket } from './_types' +import { applyCorsHeaders, CORS_UNRESTRICTED } from '../../../lib/api/cors' type Data = any[] @@ -9,6 +10,7 @@ export default async function handler( req: NextApiRequest, res: NextApiResponse<Data> ) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) const contracts = await listAllContracts() // Serve from Vercel cache, then update. see https://vercel.com/docs/concepts/functions/edge-caching res.setHeader('Cache-Control', 's-maxage=1, stale-while-revalidate') diff --git a/web/pages/api/v0/slug/[slug].ts b/web/pages/api/v0/slug/[slug].ts index 07c39a91..c4bba82a 100644 --- a/web/pages/api/v0/slug/[slug].ts +++ b/web/pages/api/v0/slug/[slug].ts @@ -3,11 +3,13 @@ import { Bet, listAllBets } from '../../../../lib/firebase/bets' import { listAllComments } from '../../../../lib/firebase/comments' import { getContractFromSlug } from '../../../../lib/firebase/contracts' import { FullMarket, ApiError, toLiteMarket } from '../_types' +import { applyCorsHeaders, CORS_UNRESTRICTED } from '../../../../lib/api/cors' export default async function handler( req: NextApiRequest, res: NextApiResponse<FullMarket | ApiError> ) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) const { slug } = req.query const contract = await getContractFromSlug(slug as string) From 53a584f37daf160b1a6a7797425be6fd0be4cd3d Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 17:35:06 -0400 Subject: [PATCH 21/31] Add in LTFF, ARC, RC --- common/charity.ts | 64 ++++++++++++++++++++++++++--- web/pages/charity/[charitySlug].tsx | 5 ++- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/common/charity.ts b/common/charity.ts index 9c9045b2..62698445 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -17,13 +17,16 @@ export const charities: Charity[] = [ name: '1Day Sooner', website: 'https://www.1daysooner.org/', preview: - 'Accelerating the development of each additional safe and effective vaccine by even a couple of days via COVID-19 human challenge trials could save thousands of lives.', + 'Accelerating the development of each vaccine by even a couple of days via COVID-19 human challenge trials could save thousands of lives.', photo: 'https://images.squarespace-cdn.com/content/v1/5f5f8496d1d7713486b6075a/666cbb5a-5335-4323-b9ea-b764edc826e1/OFFICIAL+1Day+Sooner+Logo.png', description: `1Day Sooner is a non-profit that advocates on behalf of COVID-19 challenge trial volunteers. - After a vaccine candidate is created in a lab, it is developed through a combination of pre-clinical evaluation and three phases of clinical trials that test its safety and efficacy. In traditional Phase III trials, participants receive the vaccine candidate or a placebo/active comparator, and efficacy is judged by comparing the prevalence of infection in the vaccine group and the placebo/comparator group, to test the hypothesis that significantly fewer participants in the vaccine group get infected. In these traditional trials, after receiving the treatment, participants return to their homes and their normal daily lives so as to test the treatment under real world conditions. Since only a small proportion of these participants may encounter the disease, it may take a large number of participants and a good deal of time for these trials to reveal differences between the vaccine and placebo groups. - In a human challenge trial (HCT), willing participants would receive the vaccine candidate or placebo and, after some time for the vaccine to take effect, be deliberately exposed to live coronavirus. Since exposure to the virus is guaranteed in HCTs, it may be possible to judge a vaccine candidate’s efficacy more quickly and with far fewer participants than a standard Phase III trial. While HCT efficacy results do not traditionally provide sufficient basis for licensure on their own, they could allow us to (1) more quickly weed out disappointing vaccine candidates or (2) promote the development of promising candidates in conjunction with traditional Phase III studies. - In addition, by gathering detailed data on the process of infection and vaccine protection in a clinical setting, researchers could learn information that proves extremely useful for broader vaccine and therapeutic development efforts. Altogether, there are scenarios in which the speed of HCTs and the richness of the data they provide accelerate the development of an effective and broadly accessible COVID-19 vaccine, with thousands of lives spared (depending on the pandemic’s long-term trajectory).`, + + After a vaccine candidate is created in a lab, it is developed through a combination of pre-clinical evaluation and three phases of clinical trials that test its safety and efficacy. In traditional Phase III trials, participants receive the vaccine candidate or a placebo/active comparator, and efficacy is judged by comparing the prevalence of infection in the vaccine group and the placebo/comparator group, to test the hypothesis that significantly fewer participants in the vaccine group get infected. In these traditional trials, after receiving the treatment, participants return to their homes and their normal daily lives so as to test the treatment under real world conditions. Since only a small proportion of these participants may encounter the disease, it may take a large number of participants and a good deal of time for these trials to reveal differences between the vaccine and placebo groups. + + In a human challenge trial (HCT), willing participants would receive the vaccine candidate or placebo and, after some time for the vaccine to take effect, be deliberately exposed to live coronavirus. Since exposure to the virus is guaranteed in HCTs, it may be possible to judge a vaccine candidate’s efficacy more quickly and with far fewer participants than a standard Phase III trial. While HCT efficacy results do not traditionally provide sufficient basis for licensure on their own, they could allow us to (1) more quickly weed out disappointing vaccine candidates or (2) promote the development of promising candidates in conjunction with traditional Phase III studies. + + In addition, by gathering detailed data on the process of infection and vaccine protection in a clinical setting, researchers could learn information that proves extremely useful for broader vaccine and therapeutic development efforts. Altogether, there are scenarios in which the speed of HCTs and the richness of the data they provide accelerate the development of an effective and broadly accessible COVID-19 vaccine, with thousands of lives spared (depending on the pandemic’s long-term trajectory).`, tags: ['Featured'] as CharityTag[], }, { @@ -33,10 +36,45 @@ export const charities: Charity[] = [ 'A new initiative to advance forecasting and epistemics with the aim of improving the long-term future of humanity.', photo: 'https://quantifieduncertainty.org/_next/image?url=https%3A%2F%2Fsuper-static-assets.s3.amazonaws.com%2F09bb1362-5e3f-4724-8ffd-f3235f67356f%2Fimages%2F6151ac3e-aed7-44c7-9827-399fe6e9222b.png&w=1920&q=80', - description: `QURI researches systematic practices to specify and estimate the most important parameters for the most important or scalable decisions. Research areas include forecasting, epistemics, evaluations, ontology, and estimation. We emphasize technological solutions that can heavily scale in the next 5 to 30 years. + description: `QURI researches systematic practices to specify and estimate the most important parameters for the most important or scalable decisions. Research areas include forecasting, epistemics, evaluations, ontology, and estimation. + + We emphasize technological solutions that can heavily scale in the next 5 to 30 years. + We believe that humanity’s success in the next few hundred years will lie intensely on its ability to coordinate and make good decisions. If important governmental and philanthropic bodies become significantly more effective, this will make society far more resilient to many kinds of challenges ahead.`, tags: ['Featured'] as CharityTag[], }, + { + name: 'Long-Term Future Fund', + website: 'https://funds.effectivealtruism.org/funds/far-future', + photo: 'https://app.effectivealtruism.org/logo-funds.svg', + preview: + 'Positively influence the long-term trajectory of civilization by making grants that address global catastrophic risks.', + description: `The Fund has a broad remit to make grants that promote, implement and advocate for longtermist ideas. Many of our grants aim to address potential risks from advanced artificial intelligence and to build infrastructure and advocate for longtermist projects. However, we welcome applications related to long-term institutional reform or other global catastrophic risks (e.g., pandemics or nuclear conflict). + + We intend to support: + - Projects that directly contribute to reducing existential risks through technical research, policy analysis, advocacy, and/or demonstration projects + - Training for researchers or practitioners who work to mitigate existential risks, or help with relevant recruitment efforts, or infrastructure for people working on longtermist projects + - Promoting long-term thinking`, + tags: ['Featured'] as CharityTag[], + }, + { + name: 'ARC', + website: 'https://alignment.org/', + photo: 'https://i.imgur.com/Hwg8OMP.png', + preview: 'Align future machine learning systems with human interests.', + description: `ARC is a non-profit research organization whose mission is to align future machine learning systems with human interests. Its current work focuses on developing an alignment strategy that could be adopted in industry today while scaling gracefully to future ML systems. Right now Paul Christiano and Mark Xu are researchers and Kyle Scott handles operations. + +What is “alignment”? ML systems can exhibit goal-directed behavior, but it is difficult to understand or control what they are “trying” to do. Powerful models could cause harm if they were trying to manipulate and deceive humans. The goal of intent alignment is to instead train these models to be helpful and honest. + +Motivation: We believe that modern ML techniques would lead to severe misalignment if scaled up to large enough computers and datasets. Practitioners may be able to adapt before these failures have catastrophic consequences, but we could reduce the risk by adopting scalable methods further in advance. + +What we’re working on: The best way to understand our research priorities and methodology is probably to read our report on Eliciting Latent Knowledge. At a high level, we’re trying to figure out how to train ML systems to answer questions by straightforwardly “translating” their beliefs into natural language rather than by reasoning about what a human wants to hear. + +Methodology: We’re unsatisfied with an algorithm if we can see any plausible story about how it eventually breaks down, which means that we can rule out most algorithms on paper without ever implementing them. The cost of this approach is that it may completely miss strategies that exploit important structure in realistic ML models; the benefit is that you can consider lots of ideas quickly. (More) + +Future plans: We expect to focus on similar theoretical problems in alignment until we either become more pessimistic about tractability or ARC grows enough to branch out into other areas. Over the long term we are likely to work on a combination of theoretical and empirical alignment research, collaborations with industry labs, alignment forecasting, and ML deployment policy.`, + tags: ['Featured'] as CharityTag[], + }, { name: 'Give Directly', website: 'https://www.givedirectly.org/', @@ -54,7 +92,7 @@ export const charities: Charity[] = [ photo: 'https://www.ntd-ngonetwork.org/sites/nnn/files/content/organisation/logos/2020-01-28/v2_HKLogo_Primary_RGB.jpg', preview: - 'At Helen Keller Intl, we envision a world where no one is deprived of the opportunity to live a healthy life – and reach their true potential.', + 'We envision a world where no one is deprived of the opportunity to live a healthy life – and reach their true potential.', description: 'Right now, 36 million people worldwide — most of them in developing countries — are blind.\n 90 percent of them didn’t have to lose their sight. Helen Keller International is dedicated to combating the causes and consequences of vision loss and making clear vision a reality for those most vulnerable to disease and who lack access to quality eye care.\n Last year alone, we helped provide many tens of millions of people with treatment to prevent diseases of poverty including blinding trachoma and river blindness.\n Surgeons trained by our staff also performed tens of thousands of cataract surgeries in the developing world.  And in the United States, we screened the vision of nearly 66,000 students living in some of our country’s poorest neighborhoods and provided free eyeglasses to just over 16,000 of them. ', }, @@ -68,6 +106,20 @@ export const charities: Charity[] = [ description: 'AMF (againstmalaria.com) provides funding for long-lasting insecticide-treated net (LLIN) distributions (for protection against malaria) in developing countries. There is strong evidence that distributing LLINs reduces child mortality and malaria cases. AMF conducts post-distribution surveys of completed distributions to determine whether LLINs have reached their intended destinations and how long they remain in good condition.', }, + { + name: 'Rethink Charity', + website: 'https://rethink.charity/', + photo: + 'https://process.filestackapi.com/resize=width:600,height:315,fit:max/quality=value:90/jvYvq1JFQkOqo3J8hVcJ', + preview: + 'Providing vital support to high-impact charities and charitable projects.', + description: `At Rethink Charity, we’re excited about improving the world by providing vital support to high-impact charities and charitable projects. We equip them with tools to boost their impact, through our projects that empower their donors with tax-efficient giving options and strategically coordinated matching opportunities. + What we do: + + - Rethink Charity Forward is a cause-neutral donation routing fund for high-impact charities around the world. Canadians have used RC Forward to donate $10 million to high-impact charities since the project was launched in late 2017. + + - EA Giving Tuesday supports both donors and highly effective nonprofits participating in Facebook’s annual Giving Tuesday match. In addition to setting up systems and processes, the team provides analysis-based recommendations, detailed instructions, and responsive support. The team’s goal is to make it as easy as possible for donors to direct matching dollars to highly effective nonprofits.`, + }, { name: 'Malaria Consortium', website: 'https://www.malariaconsortium.org/', diff --git a/web/pages/charity/[charitySlug].tsx b/web/pages/charity/[charitySlug].tsx index f4254293..b01f19e1 100644 --- a/web/pages/charity/[charitySlug].tsx +++ b/web/pages/charity/[charitySlug].tsx @@ -113,7 +113,10 @@ function Blurb({ text }: { text: string }) { return ( <> <div - className={clsx(' text-gray-500', !open && 'line-clamp-5')} + className={clsx( + 'whitespace-pre-line text-gray-500', + !open && 'line-clamp-5' + )} ref={ref} > {text} From bbc8915f790ad38a9c7707382cbe32a73efe2916 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 17:42:08 -0400 Subject: [PATCH 22/31] Clean up prediction market on charities --- web/pages/charity/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 57008678..5a9c77e9 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -52,12 +52,11 @@ export default function Charity() { )} <iframe - width="560" height="405" src="https://manifold.markets/embed/ManifoldMarkets/total-donations-for-manifold-for-go" title="Total donations for Manifold for Good this May (in USD)" frameBorder="0" - className="m-10" + className="m-10 w-full rounded-xl bg-white p-10" ></iframe> <div className="mt-10 text-gray-500"> From ade8eb7aae000c34826677af8c271464cf924ae5 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sat, 30 Apr 2022 18:00:51 -0400 Subject: [PATCH 23/31] Add CSPI --- common/charity.ts | 53 ++++++++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/common/charity.ts b/common/charity.ts index 62698445..4ae0e5c5 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -30,7 +30,7 @@ export const charities: Charity[] = [ tags: ['Featured'] as CharityTag[], }, { - name: 'QURI', + name: 'The Quantified Uncertainty Research Institute', website: 'https://quantifieduncertainty.org/', preview: 'A new initiative to advance forecasting and epistemics with the aim of improving the long-term future of humanity.', @@ -57,24 +57,6 @@ export const charities: Charity[] = [ - Promoting long-term thinking`, tags: ['Featured'] as CharityTag[], }, - { - name: 'ARC', - website: 'https://alignment.org/', - photo: 'https://i.imgur.com/Hwg8OMP.png', - preview: 'Align future machine learning systems with human interests.', - description: `ARC is a non-profit research organization whose mission is to align future machine learning systems with human interests. Its current work focuses on developing an alignment strategy that could be adopted in industry today while scaling gracefully to future ML systems. Right now Paul Christiano and Mark Xu are researchers and Kyle Scott handles operations. - -What is “alignment”? ML systems can exhibit goal-directed behavior, but it is difficult to understand or control what they are “trying” to do. Powerful models could cause harm if they were trying to manipulate and deceive humans. The goal of intent alignment is to instead train these models to be helpful and honest. - -Motivation: We believe that modern ML techniques would lead to severe misalignment if scaled up to large enough computers and datasets. Practitioners may be able to adapt before these failures have catastrophic consequences, but we could reduce the risk by adopting scalable methods further in advance. - -What we’re working on: The best way to understand our research priorities and methodology is probably to read our report on Eliciting Latent Knowledge. At a high level, we’re trying to figure out how to train ML systems to answer questions by straightforwardly “translating” their beliefs into natural language rather than by reasoning about what a human wants to hear. - -Methodology: We’re unsatisfied with an algorithm if we can see any plausible story about how it eventually breaks down, which means that we can rule out most algorithms on paper without ever implementing them. The cost of this approach is that it may completely miss strategies that exploit important structure in realistic ML models; the benefit is that you can consider lots of ideas quickly. (More) - -Future plans: We expect to focus on similar theoretical problems in alignment until we either become more pessimistic about tractability or ARC grows enough to branch out into other areas. Over the long term we are likely to work on a combination of theoretical and empirical alignment research, collaborations with industry labs, alignment forecasting, and ML deployment policy.`, - tags: ['Featured'] as CharityTag[], - }, { name: 'Give Directly', website: 'https://www.givedirectly.org/', @@ -131,6 +113,39 @@ Future plans: We expect to focus on similar theoretical problems in alignment un description: 'We are dedicated to ensuring our work is supported by strong evidence and remains grounded in the lessons we learn through implementation. We explore beyond current practice, to try out innovative ways – through research, implementation and policy development – to achieve effective and sustainable disease management and control.', }, + { + name: 'ARC', + website: 'https://alignment.org/', + photo: 'https://i.imgur.com/Hwg8OMP.png', + preview: 'Align future machine learning systems with human interests.', + description: `ARC is a non-profit research organization whose mission is to align future machine learning systems with human interests. Its current work focuses on developing an alignment strategy that could be adopted in industry today while scaling gracefully to future ML systems. Right now Paul Christiano and Mark Xu are researchers and Kyle Scott handles operations. + +What is “alignment”? ML systems can exhibit goal-directed behavior, but it is difficult to understand or control what they are “trying” to do. Powerful models could cause harm if they were trying to manipulate and deceive humans. The goal of intent alignment is to instead train these models to be helpful and honest. + +Motivation: We believe that modern ML techniques would lead to severe misalignment if scaled up to large enough computers and datasets. Practitioners may be able to adapt before these failures have catastrophic consequences, but we could reduce the risk by adopting scalable methods further in advance. + +What we’re working on: The best way to understand our research priorities and methodology is probably to read our report on Eliciting Latent Knowledge. At a high level, we’re trying to figure out how to train ML systems to answer questions by straightforwardly “translating” their beliefs into natural language rather than by reasoning about what a human wants to hear. + +Methodology: We’re unsatisfied with an algorithm if we can see any plausible story about how it eventually breaks down, which means that we can rule out most algorithms on paper without ever implementing them. The cost of this approach is that it may completely miss strategies that exploit important structure in realistic ML models; the benefit is that you can consider lots of ideas quickly. (More) + +Future plans: We expect to focus on similar theoretical problems in alignment until we either become more pessimistic about tractability or ARC grows enough to branch out into other areas. Over the long term we are likely to work on a combination of theoretical and empirical alignment research, collaborations with industry labs, alignment forecasting, and ML deployment policy.`, + }, + { + name: 'The Center for the Study of Partisanship and Ideology', + website: 'https://cspicenter.org/', + photo: 'https://cspicenter.org/wp-content/uploads/2020/02/CSPI.png', + preview: + 'Support and fund research on how ideology and government policy contribute to scientific, technological, and social progress.', + description: `Over the last few decades, scientific and technological progress have stagnated. Scientists conduct more research than ever before, but groundbreaking innovation is scarce. At the same time, identity politics and political polarization have reached new extremes, and social trends such as family stability and crime are worse than in previous decades and in some cases moving in the wrong direction. What explains these trends, and how can we reverse them? + + Much of the blame lies with the institutions we rely on for administration, innovation, and leadership. Instead of forward-looking governments, we have short-sighted politicians and bloated bureaucracies. Instead of real experts with proven track records, we have so-called ‘experts’ who appeal to the authority of their credentials. Instead of political leaders willing to face facts and make tough tradeoffs, we have politicians who appeal to ignorance and defer responsibility. + + To fix our institutions, we need to rethink them from the ground up. That is why CSPI supports and funds research into the administrative systems, organizational structures, and political ideologies of modern governance. Only by understanding what makes these systems so often dysfunctional can we change them for the better. + + CSPI believes that governments should be accountable to the populace as a whole, not special interest groups. We think experts should have greater say in public policy, but that there should be different standards for what qualifies as “expertise.” We want to end scientific and technological stagnation and usher in a new era of growth and innovation. + + We are interested in funding and supporting research that can speak to these issues in the social sciences through grants and fellowships. CSPI particularly seek outs work that is unlikely to receive support elsewhere. See our home page for more about the kinds of research we are particularly interested in funding.`, + }, { name: 'Faunalytics', website: 'https://faunalytics.org/', From 80d594bd5f8e959cf689c3fd4fdce1ae00bf8abc Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sun, 1 May 2022 08:30:55 -0400 Subject: [PATCH 24/31] Rename QURI back --- common/charity.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/charity.ts b/common/charity.ts index 4ae0e5c5..9754b5ea 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -12,6 +12,7 @@ export interface Charity { type CharityTag = 'Featured' // | 'Health' | 'Poverty' | 'X-Risk' | 'Animal Welfare' | 'Policy' +// Warning: 'name' is currently used as the slug and the txn toId for the charity. export const charities: Charity[] = [ { name: '1Day Sooner', @@ -30,10 +31,10 @@ export const charities: Charity[] = [ tags: ['Featured'] as CharityTag[], }, { - name: 'The Quantified Uncertainty Research Institute', + name: 'QURI', website: 'https://quantifieduncertainty.org/', preview: - 'A new initiative to advance forecasting and epistemics with the aim of improving the long-term future of humanity.', + 'The Quantified Uncertainty Research Institute advances forecasting and epistemics to improve the long-term future of humanity.', photo: 'https://quantifieduncertainty.org/_next/image?url=https%3A%2F%2Fsuper-static-assets.s3.amazonaws.com%2F09bb1362-5e3f-4724-8ffd-f3235f67356f%2Fimages%2F6151ac3e-aed7-44c7-9827-399fe6e9222b.png&w=1920&q=80', description: `QURI researches systematic practices to specify and estimate the most important parameters for the most important or scalable decisions. Research areas include forecasting, epistemics, evaluations, ontology, and estimation. From 06b7e49e98184b78ad2cc65dc4fd7571988d2e35 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 1 May 2022 11:36:54 -0500 Subject: [PATCH 25/31] [In progress] Server-side feed computation (#106) * Store view counts & last viewed time * Schedule updating user recommendations. Compute using tf-idf. * Update contract's lastBetTime and lastCommentTime on new bets and comments. * Remove contract's lastUpdatedTime * Remove folds activity feed * Implement getFeed cloud function * Hook up client to use getFeed * Script to cache viewCounts and lastViewTime * Batched wait all userRecommendations * Cache view script runs on all users * Update user feed each hour and get feed from cache doc. * Delete view cache script * Update feed script * Tweak feed algorithm * Compute recommendation scores from updateUserFeed * Disable lastViewedScore factor * Update lastCommentTime script * Comment out console.log * Fix timeout issue by calling new cloud functions with part of the work. * Listen for contract updates to feed. * Handle new user: use default feed of top markets this week * Track lastUpdatedTime * Tweak logic of calling cloud functions in batches * Tweak cloud function batching --- common/contract.ts | 4 +- common/new-contract.ts | 1 - common/recommended-contracts.ts | 89 +++++++ firestore.rules | 4 + functions/src/index.ts | 4 + functions/src/on-create-bet.ts | 28 +++ functions/src/on-create-comment.ts | 11 +- functions/src/on-view.ts | 24 ++ functions/src/scripts/cache-views.ts | 78 ++++++ functions/src/scripts/update-feed.ts | 38 +++ .../src/scripts/update-last-comment-time.ts | 43 ++++ functions/src/update-feed.ts | 210 ++++++++++++++++ functions/src/update-recommendations.ts | 71 ++++++ functions/src/utils.ts | 14 +- web/components/feed/activity-feed.tsx | 44 ++-- web/components/feed/find-active-contracts.ts | 6 +- web/hooks/use-algo-feed.ts | 231 +++--------------- web/hooks/use-contracts.ts | 36 ++- web/lib/firebase/contracts.ts | 43 ++++ web/lib/firebase/users.ts | 17 +- web/pages/fold/[...slugs]/index.tsx | 52 +--- web/pages/home.tsx | 25 +- 22 files changed, 777 insertions(+), 296 deletions(-) create mode 100644 functions/src/on-create-bet.ts create mode 100644 functions/src/on-view.ts create mode 100644 functions/src/scripts/cache-views.ts create mode 100644 functions/src/scripts/update-feed.ts create mode 100644 functions/src/scripts/update-last-comment-time.ts create mode 100644 functions/src/update-feed.ts create mode 100644 functions/src/update-recommendations.ts diff --git a/common/contract.ts b/common/contract.ts index 6e362de0..82a330b5 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -20,7 +20,9 @@ export type FullContract< visibility: 'public' | 'unlisted' createdTime: number // Milliseconds since epoch - lastUpdatedTime: number // If the question or description was changed + lastUpdatedTime?: number // Updated on new bet or comment + lastBetTime?: number + lastCommentTime?: number closeTime?: number // When no more trading is allowed isResolved: boolean diff --git a/common/new-contract.ts b/common/new-contract.ts index ffd27e3f..b86ebb71 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -53,7 +53,6 @@ export function getNewContract( visibility: 'public', isResolved: false, createdTime: Date.now(), - lastUpdatedTime: Date.now(), closeTime, volume, diff --git a/common/recommended-contracts.ts b/common/recommended-contracts.ts index 7de2e501..be50c5cd 100644 --- a/common/recommended-contracts.ts +++ b/common/recommended-contracts.ts @@ -1,8 +1,12 @@ import * as _ from 'lodash' +import { Bet } from './bet' import { Contract } from './contract' +import { ClickEvent } from './tracking' import { filterDefined } from './util/array' import { addObjects } from './util/object' +export const MAX_FEED_CONTRACTS = 75 + export const getRecommendedContracts = ( contractsById: { [contractId: string]: Contract }, yourBetOnContractIds: string[] @@ -92,3 +96,88 @@ const contractsToWordFrequency = (contracts: Contract[]) => { return toFrequency(frequencySum) } + +export const getWordScores = ( + contracts: Contract[], + contractViewCounts: { [contractId: string]: number }, + clicks: ClickEvent[], + bets: Bet[] +) => { + const contractClicks = _.groupBy(clicks, (click) => click.contractId) + const contractBets = _.groupBy(bets, (bet) => bet.contractId) + + const yourContracts = contracts.filter( + (c) => + contractViewCounts[c.id] || contractClicks[c.id] || contractBets[c.id] + ) + const yourTfIdf = calculateContractTfIdf(yourContracts) + + const contractWordScores = _.mapValues( + yourTfIdf, + (wordsTfIdf, contractId) => { + const viewCount = contractViewCounts[contractId] ?? 0 + const clickCount = contractClicks[contractId]?.length ?? 0 + const betCount = contractBets[contractId]?.length ?? 0 + + const factor = + -1 * Math.log(viewCount + 1) + + 10 * Math.log(betCount + clickCount / 4 + 1) + + return _.mapValues(wordsTfIdf, (tfIdf) => tfIdf * factor) + } + ) + + const wordScores = Object.values(contractWordScores).reduce(addObjects, {}) + const minScore = Math.min(...Object.values(wordScores)) + const maxScore = Math.max(...Object.values(wordScores)) + const normalizedWordScores = _.mapValues( + wordScores, + (score) => (score - minScore) / (maxScore - minScore) + ) + + // console.log( + // 'your word scores', + // _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(0, 100), + // _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(-100) + // ) + + return normalizedWordScores +} + +export function getContractScore( + contract: Contract, + wordScores: { [word: string]: number } +) { + if (Object.keys(wordScores).length === 0) return 1 + + const wordFrequency = contractToWordFrequency(contract) + const score = _.sumBy(Object.keys(wordFrequency), (word) => { + const wordFreq = wordFrequency[word] ?? 0 + const weight = wordScores[word] ?? 0 + return wordFreq * weight + }) + + return score +} + +// Caluculate Term Frequency-Inverse Document Frequency (TF-IDF): +// https://medium.datadriveninvestor.com/tf-idf-in-natural-language-processing-8db8ef4a7736 +function calculateContractTfIdf(contracts: Contract[]) { + const contractFreq = contracts.map((c) => contractToWordFrequency(c)) + const contractWords = contractFreq.map((freq) => Object.keys(freq)) + + const wordsCount: { [word: string]: number } = {} + for (const words of contractWords) { + for (const word of words) { + wordsCount[word] = (wordsCount[word] ?? 0) + 1 + } + } + + const wordIdf = _.mapValues(wordsCount, (count) => + Math.log(contracts.length / count) + ) + const contractWordsTfIdf = _.map(contractFreq, (wordFreq) => + _.mapValues(wordFreq, (freq, word) => freq * wordIdf[word]) + ) + return _.fromPairs(contracts.map((c, i) => [c.id, contractWordsTfIdf[i]])) +} diff --git a/firestore.rules b/firestore.rules index 65783ba1..28e03e64 100644 --- a/firestore.rules +++ b/firestore.rules @@ -35,6 +35,10 @@ service cloud.firestore { allow create: if userId == request.auth.uid; } + match /private-users/{userId}/cache/feed { + allow read: if userId == request.auth.uid || isAdmin(); + } + match /contracts/{contractId} { allow read; allow update: if request.resource.data.diff(resource.data).affectedKeys() diff --git a/functions/src/index.ts b/functions/src/index.ts index f8aa50e3..3c0dc8f8 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -13,12 +13,16 @@ export * from './create-contract' export * from './create-user' export * from './create-fold' export * from './create-answer' +export * from './on-create-bet' export * from './on-create-comment' export * from './on-fold-follow' export * from './on-fold-delete' +export * from './on-view' export * from './unsubscribe' export * from './update-contract-metrics' export * from './update-user-metrics' +export * from './update-recommendations' +export * from './update-feed' export * from './backup-db' export * from './change-user-info' export * from './market-close-emails' diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts new file mode 100644 index 00000000..deaa4c4a --- /dev/null +++ b/functions/src/on-create-bet.ts @@ -0,0 +1,28 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import * as _ from 'lodash' + +import { getContract } from './utils' +import { Bet } from '../../common/bet' + +const firestore = admin.firestore() + +export const onCreateBet = functions.firestore + .document('contracts/{contractId}/bets/{betId}') + .onCreate(async (change, context) => { + const { contractId } = context.params as { + contractId: string + } + + const contract = await getContract(contractId) + if (!contract) + throw new Error('Could not find contract corresponding with bet') + + const bet = change.data() as Bet + const lastBetTime = bet.createdTime + + await firestore + .collection('contracts') + .doc(contract.id) + .update({ lastBetTime, lastUpdatedTime: Date.now() }) + }) diff --git a/functions/src/on-create-comment.ts b/functions/src/on-create-comment.ts index 02ade1fe..18fc6757 100644 --- a/functions/src/on-create-comment.ts +++ b/functions/src/on-create-comment.ts @@ -18,12 +18,19 @@ export const onCreateComment = functions.firestore } const contract = await getContract(contractId) - if (!contract) return + if (!contract) + throw new Error('Could not find contract corresponding with comment') const comment = change.data() as Comment + const lastCommentTime = comment.createdTime const commentCreator = await getUser(comment.userId) - if (!commentCreator) return + if (!commentCreator) throw new Error('Could not find contract creator') + + await firestore + .collection('contracts') + .doc(contract.id) + .update({ lastCommentTime, lastUpdatedTime: Date.now() }) let bet: Bet | undefined let answer: Answer | undefined diff --git a/functions/src/on-view.ts b/functions/src/on-view.ts new file mode 100644 index 00000000..d2f746d5 --- /dev/null +++ b/functions/src/on-view.ts @@ -0,0 +1,24 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { View } from '../../common/tracking' + +const firestore = admin.firestore() + +export const onView = functions.firestore + .document('private-users/{userId}/views/{viewId}') + .onCreate(async (snapshot, context) => { + const { userId } = context.params + + const { contractId, timestamp } = snapshot.data() as View + + await firestore + .doc(`private-users/${userId}/cache/viewCounts`) + .set( + { [contractId]: admin.firestore.FieldValue.increment(1) }, + { merge: true } + ) + + await firestore + .doc(`private-users/${userId}/cache/lastViewTime`) + .set({ [contractId]: timestamp }, { merge: true }) + }) diff --git a/functions/src/scripts/cache-views.ts b/functions/src/scripts/cache-views.ts new file mode 100644 index 00000000..c7145a1e --- /dev/null +++ b/functions/src/scripts/cache-views.ts @@ -0,0 +1,78 @@ +import * as admin from 'firebase-admin' +import * as _ from 'lodash' + +import { initAdmin } from './script-init' +initAdmin() + +import { getValues } from '../utils' +import { View } from '../../../common/tracking' +import { User } from '../../../common/user' +import { batchedWaitAll } from '../../../common/util/promise' + +const firestore = admin.firestore() + +async function cacheViews() { + console.log('Caching views') + + const users = await getValues<User>(firestore.collection('users')) + + await batchedWaitAll( + users.map((user) => () => { + console.log('Caching views for', user.username) + return cacheUserViews(user.id) + }) + ) +} + +async function cacheUserViews(userId: string) { + const views = await getValues<View>( + firestore.collection('private-users').doc(userId).collection('views') + ) + + const viewCounts: { [contractId: string]: number } = {} + for (const view of views) { + viewCounts[view.contractId] = (viewCounts[view.contractId] ?? 0) + 1 + } + + const lastViewTime: { [contractId: string]: number } = {} + for (const view of views) { + lastViewTime[view.contractId] = Math.max( + lastViewTime[view.contractId] ?? 0, + view.timestamp + ) + } + + await firestore + .doc(`private-users/${userId}/cache/viewCounts`) + .set(viewCounts, { merge: true }) + + await firestore + .doc(`private-users/${userId}/cache/lastViewTime`) + .set(lastViewTime, { merge: true }) + + console.log(viewCounts, lastViewTime) +} + +async function deleteCache() { + console.log('Deleting view cache') + + const users = await getValues<User>(firestore.collection('users')) + + await batchedWaitAll( + users.map((user) => async () => { + console.log('Deleting view cache for', user.username) + await firestore.doc(`private-users/${user.id}/cache/viewCounts`).delete() + await firestore + .doc(`private-users/${user.id}/cache/lastViewTime`) + .delete() + await firestore + .doc(`private-users/${user.id}/cache/contractScores`) + .delete() + await firestore.doc(`private-users/${user.id}/cache/wordScores`).delete() + }) + ) +} + +if (require.main === module) { + cacheViews().then(() => process.exit()) +} diff --git a/functions/src/scripts/update-feed.ts b/functions/src/scripts/update-feed.ts new file mode 100644 index 00000000..25a0b14f --- /dev/null +++ b/functions/src/scripts/update-feed.ts @@ -0,0 +1,38 @@ +import * as admin from 'firebase-admin' +import * as _ from 'lodash' + +import { initAdmin } from './script-init' +initAdmin() + +import { getValues } from '../utils' +import { User } from '../../../common/user' +import { batchedWaitAll } from '../../../common/util/promise' +import { Contract } from '../../../common/contract' +import { updateWordScores } from '../update-recommendations' +import { getFeedContracts, doUserFeedUpdate } from '../update-feed' + +const firestore = admin.firestore() + +async function updateFeed() { + console.log('Updating feed') + + const contracts = await getValues<Contract>(firestore.collection('contracts')) + const feedContracts = await getFeedContracts() + const users = await getValues<User>( + firestore.collection('users') + // .where('username', '==', 'JamesGrugett') + ) + + await batchedWaitAll( + users.map((user) => async () => { + console.log('Updating recs for', user.username) + await updateWordScores(user, contracts) + console.log('Updating feed for', user.username) + await doUserFeedUpdate(user, feedContracts) + }) + ) +} + +if (require.main === module) { + updateFeed().then(() => process.exit()) +} diff --git a/functions/src/scripts/update-last-comment-time.ts b/functions/src/scripts/update-last-comment-time.ts new file mode 100644 index 00000000..ae950fbe --- /dev/null +++ b/functions/src/scripts/update-last-comment-time.ts @@ -0,0 +1,43 @@ +import * as admin from 'firebase-admin' +import * as _ from 'lodash' + +import { initAdmin } from './script-init' +initAdmin() + +import { Contract } from '../../../common/contract' +import { getValues } from '../utils' +import { Comment } from '../../../common/comment' + +async function updateLastCommentTime() { + const firestore = admin.firestore() + console.log('Updating contracts lastCommentTime') + + const contracts = await getValues<Contract>(firestore.collection('contracts')) + + console.log('Loaded', contracts.length, 'contracts') + + for (const contract of contracts) { + const contractRef = firestore.doc(`contracts/${contract.id}`) + + const lastComments = await getValues<Comment>( + contractRef.collection('comments').orderBy('createdTime', 'desc').limit(1) + ) + + if (lastComments.length > 0) { + const lastCommentTime = lastComments[0].createdTime + console.log( + 'Updating lastCommentTime', + contract.question, + lastCommentTime + ) + + await contractRef.update({ + lastCommentTime, + } as Partial<Contract>) + } + } +} + +if (require.main === module) { + updateLastCommentTime().then(() => process.exit()) +} diff --git a/functions/src/update-feed.ts b/functions/src/update-feed.ts new file mode 100644 index 00000000..accd48e8 --- /dev/null +++ b/functions/src/update-feed.ts @@ -0,0 +1,210 @@ +import * as _ from 'lodash' +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { getValue, getValues } from './utils' +import { Contract } from '../../common/contract' +import { logInterpolation } from '../../common/util/math' +import { DAY_MS } from '../../common/util/time' +import { + getProbability, + getOutcomeProbability, + getTopAnswer, +} from '../../common/calculate' +import { Bet } from '../../common/bet' +import { Comment } from '../../common/comment' +import { User } from '../../common/user' +import { + getContractScore, + MAX_FEED_CONTRACTS, +} from '../../common/recommended-contracts' +import { callCloudFunction } from './call-cloud-function' + +const firestore = admin.firestore() + +export const updateFeed = functions.pubsub + .schedule('every 60 minutes') + .onRun(async () => { + const users = await getValues<User>(firestore.collection('users')) + + const batchSize = 100 + const userBatches: User[][] = [] + for (let i = 0; i < users.length; i += batchSize) { + userBatches.push(users.slice(i, i + batchSize)) + } + + await Promise.all( + userBatches.map(async (users) => + callCloudFunction('updateFeedBatch', { users }) + ) + ) + }) + +export const updateFeedBatch = functions.https.onCall( + async (data: { users: User[] }) => { + const { users } = data + const contracts = await getFeedContracts() + + await Promise.all(users.map((user) => doUserFeedUpdate(user, contracts))) + } +) + +export async function getFeedContracts() { + // Get contracts bet on or created in last week. + const contracts = await Promise.all([ + getValues<Contract>( + firestore + .collection('contracts') + .where('isResolved', '==', false) + .where('volume7Days', '>', 0) + ), + + getValues<Contract>( + firestore + .collection('contracts') + .where('isResolved', '==', false) + .where('createdTime', '>', Date.now() - DAY_MS * 7) + .where('volume7Days', '==', 0) + ), + ]).then(([activeContracts, inactiveContracts]) => { + const combined = [...activeContracts, ...inactiveContracts] + // Remove closed contracts. + return combined.filter((c) => (c.closeTime ?? Infinity) > Date.now()) + }) + + return contracts +} + +export const doUserFeedUpdate = async (user: User, contracts: Contract[]) => { + const userCacheCollection = firestore.collection( + `private-users/${user.id}/cache` + ) + const [wordScores, lastViewedTime] = await Promise.all([ + getValue<{ [word: string]: number }>(userCacheCollection.doc('wordScores')), + getValue<{ [contractId: string]: number }>( + userCacheCollection.doc('lastViewTime') + ), + ]).then((dicts) => dicts.map((dict) => dict ?? {})) + + const scoredContracts = contracts.map((contract) => { + const score = scoreContract( + contract, + wordScores, + lastViewedTime[contract.id] + ) + return [contract, score] as [Contract, number] + }) + + const sortedContracts = _.sortBy( + scoredContracts, + ([_, score]) => score + ).reverse() + + // console.log(sortedContracts.map(([c, score]) => c.question + ': ' + score)) + + const feedContracts = sortedContracts + .slice(0, MAX_FEED_CONTRACTS) + .map(([c]) => c) + + const feed = await Promise.all( + feedContracts.map((contract) => getRecentBetsAndComments(contract)) + ) + + await userCacheCollection.doc('feed').set({ feed }) +} + +function scoreContract( + contract: Contract, + wordScores: { [word: string]: number }, + viewTime: number | undefined +) { + const recommendationScore = getContractScore(contract, wordScores) + const activityScore = getActivityScore(contract, viewTime) + // const lastViewedScore = getLastViewedScore(viewTime) + return recommendationScore * activityScore +} + +function getActivityScore(contract: Contract, viewTime: number | undefined) { + const { createdTime, lastBetTime, lastCommentTime, outcomeType } = contract + const hasNewComments = + lastCommentTime && (!viewTime || lastCommentTime > viewTime) + const newCommentScore = hasNewComments ? 1 : 0.5 + + const timeSinceLastComment = Date.now() - (lastCommentTime ?? createdTime) + const commentDaysAgo = timeSinceLastComment / DAY_MS + const commentTimeScore = + 0.25 + 0.75 * (1 - logInterpolation(0, 3, commentDaysAgo)) + + const timeSinceLastBet = Date.now() - (lastBetTime ?? createdTime) + const betDaysAgo = timeSinceLastBet / DAY_MS + const betTimeScore = 0.5 + 0.5 * (1 - logInterpolation(0, 3, betDaysAgo)) + + let prob = 0.5 + if (outcomeType === 'BINARY') { + prob = getProbability(contract) + } else if (outcomeType === 'FREE_RESPONSE') { + const topAnswer = getTopAnswer(contract) + if (topAnswer) + prob = Math.max(0.5, getOutcomeProbability(contract, topAnswer.id)) + } + const frac = 1 - Math.abs(prob - 0.5) ** 2 / 0.25 + const probScore = 0.5 + frac * 0.5 + + const { volume24Hours, volume7Days } = contract + const combinedVolume = Math.log(volume24Hours + 1) + Math.log(volume7Days + 1) + const volumeScore = 0.5 + 0.5 * logInterpolation(4, 20, combinedVolume) + + const score = + newCommentScore * commentTimeScore * betTimeScore * probScore * volumeScore + + // Map score to [0.5, 1] since no recent activty is not a deal breaker. + const mappedScore = 0.5 + 0.5 * score + const newMappedScore = 0.7 + 0.3 * score + + const isNew = Date.now() < contract.createdTime + DAY_MS + return isNew ? newMappedScore : mappedScore +} + +function getLastViewedScore(viewTime: number | undefined) { + if (viewTime === undefined) { + return 1 + } + + const daysAgo = (Date.now() - viewTime) / DAY_MS + + if (daysAgo < 0.5) { + const frac = logInterpolation(0, 0.5, daysAgo) + return 0.5 + 0.25 * frac + } + + const frac = logInterpolation(0.5, 14, daysAgo) + return 0.75 + 0.25 * frac +} + +async function getRecentBetsAndComments(contract: Contract) { + const contractDoc = firestore.collection('contracts').doc(contract.id) + + const [recentBets, recentComments] = await Promise.all([ + getValues<Bet>( + contractDoc + .collection('bets') + .where('createdTime', '>', Date.now() - DAY_MS) + .orderBy('createdTime', 'desc') + .limit(1) + ), + + getValues<Comment>( + contractDoc + .collection('comments') + .where('createdTime', '>', Date.now() - 3 * DAY_MS) + .orderBy('createdTime', 'desc') + .limit(3) + ), + ]) + + return { + contract, + recentBets, + recentComments, + } +} diff --git a/functions/src/update-recommendations.ts b/functions/src/update-recommendations.ts new file mode 100644 index 00000000..4e656dda --- /dev/null +++ b/functions/src/update-recommendations.ts @@ -0,0 +1,71 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import * as _ from 'lodash' + +import { getValue, getValues } from './utils' +import { Contract } from '../../common/contract' +import { Bet } from '../../common/bet' +import { User } from '../../common/user' +import { ClickEvent } from '../../common/tracking' +import { getWordScores } from '../../common/recommended-contracts' +import { batchedWaitAll } from '../../common/util/promise' +import { callCloudFunction } from './call-cloud-function' + +const firestore = admin.firestore() + +export const updateRecommendations = functions.pubsub + .schedule('every 24 hours') + .onRun(async () => { + const users = await getValues<User>(firestore.collection('users')) + + const batchSize = 100 + const userBatches: User[][] = [] + for (let i = 0; i < users.length; i += batchSize) { + userBatches.push(users.slice(i, i + batchSize)) + } + + await Promise.all( + userBatches.map((batch) => + callCloudFunction('updateRecommendationsBatch', { users: batch }) + ) + ) + }) + +export const updateRecommendationsBatch = functions.https.onCall( + async (data: { users: User[] }) => { + const { users } = data + + const contracts = await getValues<Contract>( + firestore.collection('contracts') + ) + + await batchedWaitAll( + users.map((user) => () => updateWordScores(user, contracts)) + ) + } +) + +export const updateWordScores = async (user: User, contracts: Contract[]) => { + const [bets, viewCounts, clicks] = await Promise.all([ + getValues<Bet>( + firestore.collectionGroup('bets').where('userId', '==', user.id) + ), + + getValue<{ [contractId: string]: number }>( + firestore.doc(`private-users/${user.id}/cache/viewCounts`) + ), + + getValues<ClickEvent>( + firestore + .collection(`private-users/${user.id}/events`) + .where('type', '==', 'click') + ), + ]) + + const wordScores = getWordScores(contracts, viewCounts ?? {}, clicks, bets) + + const cachedCollection = firestore.collection( + `private-users/${user.id}/cache` + ) + await cachedCollection.doc('wordScores').set(wordScores) +} diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 88c25570..28ef5445 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -6,27 +6,33 @@ import { PrivateUser, User } from '../../common/user' export const isProd = admin.instanceId().app.options.projectId === 'mantic-markets' -export const getValue = async <T>(collection: string, doc: string) => { +export const getDoc = async <T>(collection: string, doc: string) => { const snap = await admin.firestore().collection(collection).doc(doc).get() return snap.exists ? (snap.data() as T) : undefined } +export const getValue = async <T>(ref: admin.firestore.DocumentReference) => { + const snap = await ref.get() + + return snap.exists ? (snap.data() as T) : undefined +} + export const getValues = async <T>(query: admin.firestore.Query) => { const snap = await query.get() return snap.docs.map((doc) => doc.data() as T) } export const getContract = (contractId: string) => { - return getValue<Contract>('contracts', contractId) + return getDoc<Contract>('contracts', contractId) } export const getUser = (userId: string) => { - return getValue<User>('users', userId) + return getDoc<User>('users', userId) } export const getPrivateUser = (userId: string) => { - return getValue<PrivateUser>('private-users', userId) + return getDoc<PrivateUser>('private-users', userId) } export const getUserByUsername = async (username: string) => { diff --git a/web/components/feed/activity-feed.tsx b/web/components/feed/activity-feed.tsx index d7e3ab99..19ec1299 100644 --- a/web/components/feed/activity-feed.tsx +++ b/web/components/feed/activity-feed.tsx @@ -8,31 +8,27 @@ import { useUser } from '../../hooks/use-user' import { ContractActivity } from './contract-activity' export function ActivityFeed(props: { - contracts: Contract[] - recentBets: Bet[] - recentComments: Comment[] + feed: { + contract: Contract + recentBets: Bet[] + recentComments: Comment[] + }[] mode: 'only-recent' | 'abbreviated' | 'all' getContractPath?: (contract: Contract) => string }) { - const { contracts, recentBets, recentComments, mode, getContractPath } = props + const { feed, mode, getContractPath } = props const user = useUser() - const groupedBets = _.groupBy(recentBets, (bet) => bet.contractId) - const groupedComments = _.groupBy( - recentComments, - (comment) => comment.contractId - ) - return ( <FeedContainer - contracts={contracts} - renderContract={(contract) => ( + feed={feed} + renderItem={({ contract, recentBets, recentComments }) => ( <ContractActivity user={user} contract={contract} - bets={groupedBets[contract.id] ?? []} - comments={groupedComments[contract.id] ?? []} + bets={recentBets} + comments={recentComments} mode={mode} contractPath={getContractPath ? getContractPath(contract) : undefined} /> @@ -42,18 +38,26 @@ export function ActivityFeed(props: { } function FeedContainer(props: { - contracts: Contract[] - renderContract: (contract: Contract) => any + feed: { + contract: Contract + recentBets: Bet[] + recentComments: Comment[] + }[] + renderItem: (item: { + contract: Contract + recentBets: Bet[] + recentComments: Comment[] + }) => any }) { - const { contracts, renderContract } = props + const { feed, renderItem } = props return ( <Col className="items-center"> <Col className="w-full max-w-3xl"> <Col className="w-full divide-y divide-gray-300 self-center bg-white"> - {contracts.map((contract) => ( - <div key={contract.id} className="py-6 px-2 sm:px-4"> - {renderContract(contract)} + {feed.map((item) => ( + <div key={item.contract.id} className="py-6 px-2 sm:px-4"> + {renderItem(item)} </div> ))} </Col> diff --git a/web/components/feed/find-active-contracts.ts b/web/components/feed/find-active-contracts.ts index 42737f47..6f40806f 100644 --- a/web/components/feed/find-active-contracts.ts +++ b/web/components/feed/find-active-contracts.ts @@ -9,11 +9,7 @@ const MAX_ACTIVE_CONTRACTS = 75 // TODO: Maybe store last activity time directly in the contract? // Pros: simplifies this code; cons: harder to tweak "activity" definition later function lastActivityTime(contract: Contract) { - return Math.max( - contract.resolutionTime || 0, - contract.lastUpdatedTime, - contract.createdTime - ) + return Math.max(contract.resolutionTime || 0, contract.createdTime) } // Types of activity to surface: diff --git a/web/hooks/use-algo-feed.ts b/web/hooks/use-algo-feed.ts index e8d6396b..b8cfb7a2 100644 --- a/web/hooks/use-algo-feed.ts +++ b/web/hooks/use-algo-feed.ts @@ -1,213 +1,60 @@ import _ from 'lodash' -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect } from 'react' import { Bet } from '../../common/bet' import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' -import { User } from '../../common/user' -import { logInterpolation } from '../../common/util/math' -import { getRecommendedContracts } from '../../common/recommended-contracts' -import { useSeenContracts } from './use-seen-contracts' -import { useGetUserBetContractIds, useUserBetContracts } from './use-user-bets' -import { DAY_MS } from '../../common/util/time' -import { - getProbability, - getOutcomeProbability, - getTopAnswer, -} from '../../common/calculate' import { useTimeSinceFirstRender } from './use-time-since-first-render' import { trackLatency } from '../lib/firebase/tracking' +import { User } from '../../common/user' +import { getUserFeed } from '../lib/firebase/users' +import { useUpdatedContracts } from './use-contracts' +import { + getRecentBetsAndComments, + getTopWeeklyContracts, +} from '../lib/firebase/contracts' -const MAX_FEED_CONTRACTS = 75 +type feed = { + contract: Contract + recentBets: Bet[] + recentComments: Comment[] +}[] -export const useAlgoFeed = ( - user: User | null | undefined, - contracts: Contract[] | undefined, - recentBets: Bet[] | undefined, - recentComments: Comment[] | undefined -) => { - const initialContracts = useMemo(() => contracts, [!!contracts]) - const initialBets = useMemo(() => recentBets, [!!recentBets]) - const initialComments = useMemo(() => recentComments, [!!recentComments]) - - const yourBetContractIds = useGetUserBetContractIds(user?.id) - // Update user bet contracts in local storage. - useUserBetContracts(user?.id) - - const seenContracts = useSeenContracts() - - const [algoFeed, setAlgoFeed] = useState<Contract[]>([]) +export const useAlgoFeed = (user: User | null | undefined) => { + const [feed, setFeed] = useState<feed>() const getTime = useTimeSinceFirstRender() useEffect(() => { - if ( - initialContracts && - initialBets && - initialComments && - yourBetContractIds - ) { - const eligibleContracts = initialContracts.filter( - (c) => !c.isResolved && (c.closeTime ?? Infinity) > Date.now() - ) - const contracts = getAlgoFeed( - eligibleContracts, - initialBets, - initialComments, - yourBetContractIds, - seenContracts - ) - setAlgoFeed(contracts) - trackLatency('feed', getTime()) + if (user) { + getUserFeed(user.id).then((feed) => { + if (feed.length === 0) { + getDefaultFeed().then((feed) => setFeed(feed)) + } else setFeed(feed) + + trackLatency('feed', getTime()) + console.log('feed load time', getTime()) + }) } - }, [ - initialBets, - initialComments, - initialContracts, - seenContracts, - yourBetContractIds, - getTime, - ]) + }, [user?.id]) - return algoFeed + return useUpdateFeed(feed) } -const getAlgoFeed = ( - contracts: Contract[], - recentBets: Bet[], - recentComments: Comment[], - yourBetContractIds: string[], - seenContracts: { [contractId: string]: number } -) => { - const contractsById = _.keyBy(contracts, (c) => c.id) +const useUpdateFeed = (feed: feed | undefined) => { + const contracts = useUpdatedContracts(feed?.map((item) => item.contract)) - const recommended = getRecommendedContracts(contractsById, yourBetContractIds) - const confidence = logInterpolation(0, 100, yourBetContractIds.length) - const recommendedScores = _.fromPairs( - recommended.map((c, index) => { - const score = 1 - index / recommended.length - const withConfidence = score * confidence + (1 - confidence) - return [c.id, withConfidence] as [string, number] - }) - ) - - const seenScores = _.fromPairs( - contracts.map( - (c) => [c.id, getSeenContractsScore(c, seenContracts)] as [string, number] - ) - ) - - const activityScores = getContractsActivityScores( - contracts, - recentComments, - recentBets, - seenContracts - ) - - const combinedScores = contracts.map((contract) => { - const score = - (recommendedScores[contract.id] ?? 0) * - (seenScores[contract.id] ?? 0) * - (activityScores[contract.id] ?? 0) - return { contract, score } - }) - - const sorted = _.sortBy(combinedScores, (c) => -c.score) - return sorted.map((c) => c.contract).slice(0, MAX_FEED_CONTRACTS) + return feed && contracts + ? feed.map(({ contract, ...other }, i) => ({ + ...other, + contract: contracts[i], + })) + : undefined } -function getContractsActivityScores( - contracts: Contract[], - recentComments: Comment[], - recentBets: Bet[], - seenContracts: { [contractId: string]: number } -) { - const contractBets = _.groupBy(recentBets, (bet) => bet.contractId) - const contractMostRecentBet = _.mapValues( - contractBets, - (bets) => _.maxBy(bets, (bet) => bet.createdTime) as Bet +const getDefaultFeed = async () => { + const contracts = await getTopWeeklyContracts() + const feed = await Promise.all( + contracts.map((c) => getRecentBetsAndComments(c)) ) - - const contractComments = _.groupBy( - recentComments, - (comment) => comment.contractId - ) - const contractMostRecentComment = _.mapValues( - contractComments, - (comments) => _.maxBy(comments, (c) => c.createdTime) as Comment - ) - - const scoredContracts = contracts.map((contract) => { - const { outcomeType } = contract - - const seenTime = seenContracts[contract.id] - const lastCommentTime = contractMostRecentComment[contract.id]?.createdTime - const hasNewComments = - !seenTime || (lastCommentTime && lastCommentTime > seenTime) - const newCommentScore = hasNewComments ? 1 : 0.5 - - const commentCount = contractComments[contract.id]?.length ?? 0 - const betCount = contractBets[contract.id]?.length ?? 0 - const activtyCount = betCount + commentCount * 5 - const activityCountScore = - 0.5 + 0.5 * logInterpolation(0, 200, activtyCount) - - const { volume7Days, volume } = contract - const combinedVolume = Math.log(volume7Days + 1) + Math.log(volume + 1) - const volumeScore = 0.5 + 0.5 * logInterpolation(4, 25, combinedVolume) - - const lastBetTime = - contractMostRecentBet[contract.id]?.createdTime ?? contract.createdTime - const timeSinceLastBet = Date.now() - lastBetTime - const daysAgo = timeSinceLastBet / DAY_MS - const timeAgoScore = 1 - logInterpolation(0, 3, daysAgo) - - let prob = 0.5 - if (outcomeType === 'BINARY') { - prob = getProbability(contract) - } else if (outcomeType === 'FREE_RESPONSE') { - const topAnswer = getTopAnswer(contract) - if (topAnswer) - prob = Math.max(0.5, getOutcomeProbability(contract, topAnswer.id)) - } - const frac = 1 - Math.abs(prob - 0.5) ** 2 / 0.25 - const probScore = 0.5 + frac * 0.5 - - const score = - newCommentScore * - activityCountScore * - volumeScore * - timeAgoScore * - probScore - - // Map score to [0.5, 1] since no recent activty is not a deal breaker. - const mappedScore = 0.5 + score / 2 - const newMappedScore = 0.75 + score / 4 - - const isNew = Date.now() < contract.createdTime + DAY_MS - const activityScore = isNew ? newMappedScore : mappedScore - - return [contract.id, activityScore] as [string, number] - }) - - return _.fromPairs(scoredContracts) -} - -function getSeenContractsScore( - contract: Contract, - seenContracts: { [contractId: string]: number } -) { - const lastSeen = seenContracts[contract.id] - if (lastSeen === undefined) { - return 1 - } - - const daysAgo = (Date.now() - lastSeen) / DAY_MS - - if (daysAgo < 0.5) { - const frac = logInterpolation(0, 0.5, daysAgo) - return 0.5 * frac - } - - const frac = logInterpolation(0.5, 14, daysAgo) - return 0.5 + 0.5 * frac + return feed } diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index c6d2be0e..0402613f 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -1,8 +1,9 @@ import _ from 'lodash' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Contract, listenForActiveContracts, + listenForContract, listenForContracts, listenForHotContracts, listenForInactiveContracts, @@ -71,3 +72,36 @@ export const useHotContracts = () => { return hotContracts } + +export const useUpdatedContracts = (contracts: Contract[] | undefined) => { + const [__, triggerUpdate] = useState(0) + const contractDict = useRef<{ [id: string]: Contract }>({}) + + useEffect(() => { + if (contracts === undefined) return + + contractDict.current = _.fromPairs(contracts.map((c) => [c.id, c])) + + const disposes = contracts.map((contract) => { + const { id } = contract + + return listenForContract(id, (contract) => { + const curr = contractDict.current[id] + if (!_.isEqual(curr, contract)) { + contractDict.current[id] = contract as Contract + triggerUpdate((n) => n + 1) + } + }) + }) + + triggerUpdate((n) => n + 1) + + return () => { + disposes.forEach((dispose) => dispose()) + } + }, [!!contracts]) + + return contracts && Object.keys(contractDict.current).length > 0 + ? contracts.map((c) => contractDict.current[c.id]) + : undefined +} diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index f41d6902..f1ab7bba 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -23,6 +23,9 @@ import { createRNG, shuffle } from '../../../common/util/random' import { getCpmmProbability } from '../../../common/calculate-cpmm' import { formatMoney, formatPercent } from '../../../common/util/format' import { DAY_MS } from '../../../common/util/time' +import { MAX_FEED_CONTRACTS } from '../../../common/recommended-contracts' +import { Bet } from '../../../common/bet' +import { Comment } from '../../../common/comment' export type { Contract } export function contractPath(contract: Contract) { @@ -231,6 +234,16 @@ export async function getHotContracts() { ) } +const topWeeklyQuery = query( + contractCollection, + where('isResolved', '==', false), + orderBy('volume7Days', 'desc'), + limit(MAX_FEED_CONTRACTS) +) +export async function getTopWeeklyContracts() { + return await getValues<Contract>(topWeeklyQuery) +} + const closingSoonQuery = query( contractCollection, where('isResolved', '==', false), @@ -276,3 +289,33 @@ export async function getDailyContracts( return contractsByDay } + +export async function getRecentBetsAndComments(contract: Contract) { + const contractDoc = doc(db, 'contracts', contract.id) + + const [recentBets, recentComments] = await Promise.all([ + getValues<Bet>( + query( + collection(contractDoc, 'bets'), + where('createdTime', '>', Date.now() - DAY_MS), + orderBy('createdTime', 'desc'), + limit(1) + ) + ), + + getValues<Comment>( + query( + collection(contractDoc, 'comments'), + where('createdTime', '>', Date.now() - 3 * DAY_MS), + orderBy('createdTime', 'desc'), + limit(3) + ) + ), + ]) + + return { + contract, + recentBets, + recentComments, + } +} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index e7805626..e07d138c 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -23,8 +23,11 @@ import _ from 'lodash' import { app } from './init' import { PrivateUser, User } from '../../../common/user' import { createUser } from './api-call' -import { getValues, listenForValue, listenForValues } from './utils' +import { getValue, getValues, listenForValue, listenForValues } from './utils' import { DAY_MS } from '../../../common/util/time' +import { Contract } from './contracts' +import { Bet } from './bets' +import { Comment } from './comments' export type { User } @@ -207,3 +210,15 @@ export async function getDailyNewUsers( return usersByDay } + +export async function getUserFeed(userId: string) { + const feedDoc = doc(db, 'private-users', userId, 'cache', 'feed') + const userFeed = await getValue<{ + feed: { + contract: Contract + recentBets: Bet[] + recentComments: Comment[] + }[] + }>(feedDoc) + return userFeed?.feed ?? [] +} diff --git a/web/pages/fold/[...slugs]/index.tsx b/web/pages/fold/[...slugs]/index.tsx index 79cd6340..b0d52793 100644 --- a/web/pages/fold/[...slugs]/index.tsx +++ b/web/pages/fold/[...slugs]/index.tsx @@ -1,5 +1,4 @@ import _ from 'lodash' -import Link from 'next/link' import { Fold } from '../../../../common/fold' import { Comment } from '../../../../common/comment' @@ -23,22 +22,17 @@ import { useUser } from '../../../hooks/use-user' import { useFold } from '../../../hooks/use-fold' import { SearchableGrid } from '../../../components/contract/contracts-list' import { useRouter } from 'next/router' -import clsx from 'clsx' import { scoreCreators, scoreTraders } from '../../../../common/scoring' import { Leaderboard } from '../../../components/leaderboard' -import { formatMoney, toCamelCase } from '../../../../common/util/format' +import { formatMoney } from '../../../../common/util/format' import { EditFoldButton } from '../../../components/folds/edit-fold-button' import Custom404 from '../../404' import { FollowFoldButton } from '../../../components/folds/follow-fold-button' -import FeedCreate from '../../../components/feed-create' import { SEO } from '../../../components/SEO' import { useTaggedContracts } from '../../../hooks/use-contracts' import { Linkify } from '../../../components/linkify' import { fromPropz, usePropz } from '../../../hooks/use-propz' import { filterDefined } from '../../../../common/util/array' -import { useRecentBets } from '../../../hooks/use-bets' -import { useRecentComments } from '../../../hooks/use-comments' -import { LoadingIndicator } from '../../../components/loading-indicator' import { findActiveContracts } from '../../../components/feed/find-active-contracts' import { Tabs } from '../../../components/layout/tabs' @@ -149,12 +143,6 @@ export default function FoldPage(props: { const contracts = filterDefined( props.contracts.map((contract) => contractsMap[contract.id]) ) - const activeContracts = filterDefined( - props.activeContracts.map((contract) => contractsMap[contract.id]) - ) - - const recentBets = useRecentBets() - const recentComments = useRecentComments() if (fold === null || !foldSubpages.includes(page) || slugs[2]) { return <Custom404 /> @@ -178,37 +166,6 @@ export default function FoldPage(props: { </Col> ) - const activityTab = ( - <Col className="flex-1"> - {user !== null && !fold.disallowMarketCreation && ( - <FeedCreate - className={clsx('border-b-2')} - user={user} - tag={toCamelCase(fold.name)} - placeholder={`Type your question about ${fold.name}`} - /> - )} - {recentBets && recentComments ? ( - <> - <ActivityFeed - contracts={activeContracts} - recentBets={recentBets ?? []} - recentComments={recentComments ?? []} - mode="abbreviated" - /> - {activeContracts.length === 0 && ( - <div className="mx-2 mt-4 text-gray-500 lg:mx-0"> - No activity from matching markets.{' '} - {isCurator && 'Try editing to add more tags!'} - </div> - )} - </> - ) : ( - <LoadingIndicator className="mt-4" /> - )} - </Col> - ) - const leaderboardsTab = ( <Col className="gap-8 px-4 lg:flex-row"> <FoldLeaderboards @@ -248,13 +205,8 @@ export default function FoldPage(props: { </div> <Tabs - defaultIndex={page === 'leaderboards' ? 2 : page === 'markets' ? 1 : 0} + defaultIndex={page === 'leaderboards' ? 1 : 0} tabs={[ - { - title: 'Activity', - content: activityTab, - href: foldPath(fold), - }, { title: 'Markets', content: <SearchableGrid contracts={contracts} />, diff --git a/web/pages/home.tsx b/web/pages/home.tsx index d593935c..6f1ec93c 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -9,30 +9,19 @@ import { Spacer } from '../components/layout/spacer' import { Col } from '../components/layout/col' import { useUser } from '../hooks/use-user' import { LoadingIndicator } from '../components/loading-indicator' -import { useRecentBets } from '../hooks/use-bets' -import { useActiveContracts } from '../hooks/use-contracts' -import { useRecentComments } from '../hooks/use-comments' import { useAlgoFeed } from '../hooks/use-algo-feed' import { ContractPageContent } from './[username]/[contractSlug]' const Home = () => { const user = useUser() - const contracts = useActiveContracts() - const contractsDict = _.keyBy(contracts, 'id') - - const recentBets = useRecentBets() - const recentComments = useRecentComments() - - const feedContracts = useAlgoFeed(user, contracts, recentBets, recentComments) - - const updatedContracts = feedContracts.map( - (contract) => contractsDict[contract.id] ?? contract - ) + const feed = useAlgoFeed(user) const router = useRouter() const { u: username, s: slug } = router.query - const contract = feedContracts.find((c) => c.slug === slug) + const contract = feed?.find( + ({ contract }) => contract.slug === slug + )?.contract useEffect(() => { // If the page initially loads with query params, redirect to the contract page. @@ -54,11 +43,9 @@ const Home = () => { <Col className="w-full max-w-[700px]"> <FeedCreate user={user ?? undefined} /> <Spacer h={10} /> - {contracts && recentBets && recentComments ? ( + {feed ? ( <ActivityFeed - contracts={updatedContracts} - recentBets={recentBets} - recentComments={recentComments} + feed={feed} mode="only-recent" getContractPath={(c) => `home?u=${c.creatorUsername}&s=${c.slug}` From 1fa214ed48a2e5f5a3daf5559a5ef858ec81aade Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 2 May 2022 07:23:41 -0400 Subject: [PATCH 26/31] Add Givewell MIF --- common/charity.ts | 53 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/common/charity.ts b/common/charity.ts index 9754b5ea..ab769655 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -58,6 +58,42 @@ export const charities: Charity[] = [ - Promoting long-term thinking`, tags: ['Featured'] as CharityTag[], }, + { + name: 'GiveWell Maximum Impact Fund', + website: 'https://www.givewell.org/maximum-impact-fund', + photo: 'https://www.givewell.org/sites/all/themes/gw_basic/logo.png', + preview: + 'We search for the charities that save or improve lives the most per dollar.', + description: ` + GiveWell is a nonprofit dedicated to finding outstanding giving opportunities and publishing the full details of our analysis to help donors decide where to give. + + We don't focus solely on financials, such as assessing administrative or fundraising costs. Instead, we conduct in-depth research to determine how much good a given program accomplishes (in terms of lives saved, lives improved, etc.) per dollar spent. Rather than rating as many charities as possible, we focus on the few charities that stand out most (by our criteria) in order to find and confidently recommend high-impact giving opportunities (our list of top charities). + + Our top recommendation to GiveWell donors seeking to do the most good possible is to donate to the Maximum Impact Fund. Donations to the Maximum Impact Fund are granted each quarter. We use our latest research to grant the funds to the recommended charity (or charities) where we believe they’ll do the most good. + + We grant funds from the Maximum Impact Fund to the recipient charity (or charities) at the end of each fiscal quarter. Our research team decides which charities have the highest priority funding needs at that time. This decision takes into consideration factors such as: + + - Which funding gaps we expect to be filled and unfilled + - Each charity’s plans for additional funding + - The cost-effectiveness of each funding gap`, + }, + { + name: 'ARC', + website: 'https://alignment.org/', + photo: 'https://i.imgur.com/Hwg8OMP.png', + preview: 'Align future machine learning systems with human interests.', + description: `ARC is a non-profit research organization whose mission is to align future machine learning systems with human interests. Its current work focuses on developing an alignment strategy that could be adopted in industry today while scaling gracefully to future ML systems. Right now Paul Christiano and Mark Xu are researchers and Kyle Scott handles operations. + +What is “alignment”? ML systems can exhibit goal-directed behavior, but it is difficult to understand or control what they are “trying” to do. Powerful models could cause harm if they were trying to manipulate and deceive humans. The goal of intent alignment is to instead train these models to be helpful and honest. + +Motivation: We believe that modern ML techniques would lead to severe misalignment if scaled up to large enough computers and datasets. Practitioners may be able to adapt before these failures have catastrophic consequences, but we could reduce the risk by adopting scalable methods further in advance. + +What we’re working on: The best way to understand our research priorities and methodology is probably to read our report on Eliciting Latent Knowledge. At a high level, we’re trying to figure out how to train ML systems to answer questions by straightforwardly “translating” their beliefs into natural language rather than by reasoning about what a human wants to hear. + +Methodology: We’re unsatisfied with an algorithm if we can see any plausible story about how it eventually breaks down, which means that we can rule out most algorithms on paper without ever implementing them. The cost of this approach is that it may completely miss strategies that exploit important structure in realistic ML models; the benefit is that you can consider lots of ideas quickly. (More) + +Future plans: We expect to focus on similar theoretical problems in alignment until we either become more pessimistic about tractability or ARC grows enough to branch out into other areas. Over the long term we are likely to work on a combination of theoretical and empirical alignment research, collaborations with industry labs, alignment forecasting, and ML deployment policy.`, + }, { name: 'Give Directly', website: 'https://www.givedirectly.org/', @@ -114,23 +150,6 @@ export const charities: Charity[] = [ description: 'We are dedicated to ensuring our work is supported by strong evidence and remains grounded in the lessons we learn through implementation. We explore beyond current practice, to try out innovative ways – through research, implementation and policy development – to achieve effective and sustainable disease management and control.', }, - { - name: 'ARC', - website: 'https://alignment.org/', - photo: 'https://i.imgur.com/Hwg8OMP.png', - preview: 'Align future machine learning systems with human interests.', - description: `ARC is a non-profit research organization whose mission is to align future machine learning systems with human interests. Its current work focuses on developing an alignment strategy that could be adopted in industry today while scaling gracefully to future ML systems. Right now Paul Christiano and Mark Xu are researchers and Kyle Scott handles operations. - -What is “alignment”? ML systems can exhibit goal-directed behavior, but it is difficult to understand or control what they are “trying” to do. Powerful models could cause harm if they were trying to manipulate and deceive humans. The goal of intent alignment is to instead train these models to be helpful and honest. - -Motivation: We believe that modern ML techniques would lead to severe misalignment if scaled up to large enough computers and datasets. Practitioners may be able to adapt before these failures have catastrophic consequences, but we could reduce the risk by adopting scalable methods further in advance. - -What we’re working on: The best way to understand our research priorities and methodology is probably to read our report on Eliciting Latent Knowledge. At a high level, we’re trying to figure out how to train ML systems to answer questions by straightforwardly “translating” their beliefs into natural language rather than by reasoning about what a human wants to hear. - -Methodology: We’re unsatisfied with an algorithm if we can see any plausible story about how it eventually breaks down, which means that we can rule out most algorithms on paper without ever implementing them. The cost of this approach is that it may completely miss strategies that exploit important structure in realistic ML models; the benefit is that you can consider lots of ideas quickly. (More) - -Future plans: We expect to focus on similar theoretical problems in alignment until we either become more pessimistic about tractability or ARC grows enough to branch out into other areas. Over the long term we are likely to work on a combination of theoretical and empirical alignment research, collaborations with industry labs, alignment forecasting, and ML deployment policy.`, - }, { name: 'The Center for the Study of Partisanship and Ideology', website: 'https://cspicenter.org/', From b63cc176305bfdc3628584c55e7b8d46f133483a Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 2 May 2022 08:45:05 -0400 Subject: [PATCH 27/31] Hide charity names on cards --- web/components/charity/charity-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx index d1763ca8..113a58ff 100644 --- a/web/components/charity/charity-card.tsx +++ b/web/components/charity/charity-card.tsx @@ -26,7 +26,7 @@ export function CharityCard(props: { charity: Charity }) { )} </figure> <div className="card-body"> - <h3 className="card-title line-clamp-3">{name}</h3> + {/* <h3 className="card-title line-clamp-3">{name}</h3> */} <div className="line-clamp-4 text-sm">{preview}</div> {raised > 0 && ( <Row className="text-primary mt-4 flex-1 items-end justify-center gap-2"> From 0a63a0ae1fbef15c8106afe09168952603818a36 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 2 May 2022 10:14:40 -0400 Subject: [PATCH 28/31] Update README link to Discord --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b480f60c..c2883c60 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Since we are just now open-sourcing things, we will see how things go. Feel free By contributing to this codebase, you are agreeing to the terms of the [Manifold CLA](https://github.com/manifoldmarkets/manifold/blob/main/.github/CONTRIBUTING.md). -If you need additional access to any infrastructure in order to work on something (e.g. Vercel, Firebase) let us know about that on Discord as well. +If you need additional access to any infrastructure in order to work on something (e.g. Vercel, Firebase) let us know about that on [Discord][discord] as well. [vercel]: https://vercel.com/ [jamstack]: https://jamstack.org/ @@ -50,4 +50,4 @@ If you need additional access to any infrastructure in order to work on somethin [cloud-firestore]: https://firebase.google.com/docs/firestore [cloud-functions]: https://firebase.google.com/docs/functions [small-prs]: https://google.github.io/eng-practices/review/developer/small-cls.html -[discord]: https://discord.gg/eHQBNBqXuh +[discord]: https://discord.gg/3Zuth9792G From d6a9b89c43153398fab8fc2af9e78b3ef5f7fe46 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 2 May 2022 10:35:46 -0400 Subject: [PATCH 29/31] Fix answers not wrapping in cards --- web/components/outcome-label.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index b2bc2f03..95403c75 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import { Answer } from '../../common/answer' import { getProbability } from '../../common/calculate' import { @@ -126,7 +127,14 @@ export function AnswerLabel(props: { truncated = text.slice(0, 75) + '...' } - return <span className={className}>{truncated}</span> + return ( + <span + style={{ wordBreak: 'break-word' }} + className={clsx('whitespace-pre-line break-words', className)} + > + {truncated} + </span> + ) } function FreeResponseAnswerToolTip(props: { From 0b5b0bb9d31c6aa5834616464c3073f05d1caa6c Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 2 May 2022 10:43:17 -0400 Subject: [PATCH 30/31] Clear comment after submitting, for multiple bets --- web/components/feed/feed-items.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index c447f666..8d09b677 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -385,6 +385,7 @@ export function FeedBet(props: { async function submitComment() { if (!user || !comment || !canComment) return await createComment(contract.id, comment, user, id) + setComment('') } const bought = amount >= 0 ? 'bought' : 'sold' From 47292d33eb865475bf5e20e9aaddc8b033b9e89c Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 2 May 2022 17:29:59 -0400 Subject: [PATCH 31/31] condense comment logic in one component --- web/components/feed/feed-items.tsx | 151 +++++++++++++---------------- 1 file changed, 68 insertions(+), 83 deletions(-) diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index 8d09b677..47773d1a 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -227,35 +227,27 @@ export function CommentInput(props: { const user = useUser() const [comment, setComment] = useState('') - async function submitComment() { - if (!comment) return - if (!user) { - return await firebaseLogin() - } - await createComment(contract.id, comment, user) - setComment('') - } - // Should this be oldest bet or most recent bet? const mostRecentCommentableBet = betsByCurrentUser .filter( (bet) => - canCommentOnBet(bet.userId, bet.createdTime, user) && + canCommentOnBet(bet, bet.createdTime, user) && !comments.some((comment) => comment.betId == bet.id) ) .sort((b1, b2) => b1.createdTime - b2.createdTime) .pop() - if (mostRecentCommentableBet) { - return ( - <FeedBet - contract={contract} - bet={mostRecentCommentableBet} - hideOutcome={false} - smallAvatar={false} - /> - ) + const { id } = mostRecentCommentableBet || { id: undefined } + + async function submitComment(id: string | undefined) { + if (!comment) return + if (!user) { + return await firebaseLogin() + } + await createComment(contract.id, comment, user, id) + setComment('') } + const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } = getBettorsPosition(contract, Date.now(), betsByCurrentUser) @@ -267,7 +259,14 @@ export function CommentInput(props: { </div> <div className={'min-w-0 flex-1 py-1.5'}> <div className="text-sm text-gray-500"> - {user && userPosition > 0 && ( + {mostRecentCommentableBet && ( + <BetStatusText + contract={contract} + bet={mostRecentCommentableBet} + isSelf={true} + /> + )} + {!mostRecentCommentableBet && user && userPosition > 0 && ( <> {'You have ' + userPositionMoney + ' '} <> @@ -290,7 +289,7 @@ export function CommentInput(props: { maxLength={MAX_COMMENT_LENGTH} onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - submitComment() + submitComment(id) } }} /> @@ -298,7 +297,7 @@ export function CommentInput(props: { className={ 'btn btn-outline btn-sm text-transform: mt-1 capitalize' } - onClick={submitComment} + onClick={() => submitComment(id)} > {user ? 'Comment' : 'Sign in to comment'} </button> @@ -372,24 +371,12 @@ export function FeedBet(props: { bet: Bet hideOutcome: boolean smallAvatar: boolean - hideComment?: boolean bettor?: User // If set: reveal bettor identity }) { - const { contract, bet, hideOutcome, smallAvatar, bettor, hideComment } = props - const { id, amount, outcome, createdTime, userId } = bet + const { contract, bet, hideOutcome, smallAvatar, bettor } = props + const { userId } = bet const user = useUser() const isSelf = user?.id === userId - const canComment = canCommentOnBet(userId, createdTime, user) && !hideComment - - const [comment, setComment] = useState('') - async function submitComment() { - if (!user || !comment || !canComment) return - await createComment(contract.id, comment, user, id) - setComment('') - } - - const bought = amount >= 0 ? 'bought' : 'sold' - const money = formatMoney(Math.abs(amount)) return ( <> @@ -421,52 +408,52 @@ export function FeedBet(props: { )} </div> <div className={'min-w-0 flex-1 py-1.5'}> - <div className="text-sm text-gray-500"> - <span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span>{' '} - {bought} {money} - {!hideOutcome && ( - <> - {' '} - of{' '} - <OutcomeLabel - outcome={outcome} - contract={contract} - truncate="short" - /> - </> - )} - <RelativeTimestamp time={createdTime} /> - {(canComment || comment) && ( - <div className="mt-2"> - <Textarea - value={comment} - onChange={(e) => setComment(e.target.value)} - className="textarea textarea-bordered w-full resize-none" - placeholder="Add a comment..." - rows={3} - maxLength={MAX_COMMENT_LENGTH} - onKeyDown={(e) => { - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - submitComment() - } - }} - /> - <button - className="btn btn-outline btn-sm text-transform: mt-1 capitalize" - onClick={submitComment} - disabled={!canComment} - > - Comment - </button> - </div> - )} - </div> + <BetStatusText + bet={bet} + contract={contract} + isSelf={isSelf} + hideOutcome={hideOutcome} + bettor={bettor} + /> </div> </Row> </> ) } +function BetStatusText(props: { + contract: Contract + bet: Bet + isSelf: boolean + hideOutcome?: boolean + bettor?: User +}) { + const { bet, contract, hideOutcome, bettor, isSelf } = props + const { amount, outcome, createdTime } = bet + + const bought = amount >= 0 ? 'bought' : 'sold' + const money = formatMoney(Math.abs(amount)) + + return ( + <div className="text-sm text-gray-500"> + <span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span> {bought}{' '} + {money} + {!hideOutcome && ( + <> + {' '} + of{' '} + <OutcomeLabel + outcome={outcome} + contract={contract} + truncate="short" + /> + </> + )} + <RelativeTimestamp time={createdTime} /> + </div> + ) +} + function TruncatedComment(props: { comment: string moreHref: string @@ -573,14 +560,12 @@ export function FeedQuestion(props: { ) } -function canCommentOnBet( - userId: string, - createdTime: number, - user?: User | null -) { - const isSelf = user?.id === userId +function canCommentOnBet(bet: Bet, createdTime: number, user?: User | null) { + const isSelf = user?.id === bet.userId // You can comment if your bet was posted in the last hour - return isSelf && Date.now() - createdTime < 60 * 60 * 1000 + return ( + !bet.isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000 + ) } function FeedDescription(props: { contract: Contract }) {