Merge branch 'main' of https://github.com/marsteralex/manifold
This commit is contained in:
		
						commit
						b98a6caee3
					
				|  | @ -40,12 +40,14 @@ export type User = { | |||
|   referredByContractId?: string | ||||
|   referredByGroupId?: string | ||||
|   lastPingTime?: number | ||||
|   shouldShowWelcome?: boolean | ||||
| } | ||||
| 
 | ||||
| export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 | ||||
| // for sus users, i.e. multiple sign ups for same person
 | ||||
| export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10 | ||||
| export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500 | ||||
| 
 | ||||
| export type PrivateUser = { | ||||
|   id: string // same as User.id
 | ||||
|   username: string // denormalized from User
 | ||||
|  | @ -55,6 +57,7 @@ export type PrivateUser = { | |||
|   unsubscribedFromCommentEmails?: boolean | ||||
|   unsubscribedFromAnswerEmails?: boolean | ||||
|   unsubscribedFromGenericEmails?: boolean | ||||
|   manaBonusEmailSent?: boolean | ||||
|   initialDeviceToken?: string | ||||
|   initialIpAddress?: string | ||||
|   apiKey?: string | ||||
|  |  | |||
							
								
								
									
										115
									
								
								docs/docs/api.md
									
									
									
									
									
								
							
							
						
						
									
										115
									
								
								docs/docs/api.md
									
									
									
									
									
								
							|  | @ -46,6 +46,28 @@ Gets a user by their unique ID. Many other API endpoints return this as the `use | |||
| 
 | ||||
| Requires no authorization. | ||||
| 
 | ||||
| ### GET /v0/me | ||||
| 
 | ||||
| Returns the authenticated user. | ||||
| 
 | ||||
| ### `GET /v0/groups` | ||||
| 
 | ||||
| Gets all groups, in no particular order. | ||||
| 
 | ||||
| Requires no authorization. | ||||
| 
 | ||||
| ### `GET /v0/groups/[slug]` | ||||
| 
 | ||||
| Gets a group by its slug. | ||||
| 
 | ||||
| Requires no authorization. | ||||
| 
 | ||||
| ### `GET /v0/groups/by-id/[id]` | ||||
| 
 | ||||
| Gets a group by its unique ID. | ||||
| 
 | ||||
| Requires no authorization. | ||||
| 
 | ||||
| ### `GET /v0/markets` | ||||
| 
 | ||||
| Lists all markets, ordered by creation date descending. | ||||
|  | @ -481,6 +503,20 @@ Parameters: | |||
|   answer. For numeric markets, this is a string representing the target bucket, | ||||
|   and an additional `value` parameter is required which is a number representing | ||||
|   the target value. (Bet on numeric markets at your own peril.) | ||||
| - `limitProb`: Optional. A number between `0.001` and `0.999` inclusive representing | ||||
|   the limit probability for your bet (i.e. 0.1% to 99.9% — multiply by 100 for the | ||||
|   probability percentage). | ||||
|   The bet will execute immediately in the direction of `outcome`, but not beyond this | ||||
|   specified limit. If not all the bet is filled, the bet will remain as an open offer | ||||
|   that can later be matched against an opposite direction bet. | ||||
|   - For example, if the current market probability is `50%`: | ||||
|     - A `M$10` bet on `YES` with `limitProb=0.4` would not be filled until the market | ||||
|       probability moves down to `40%` and someone bets `M$15` of `NO` to match your | ||||
|       bet odds. | ||||
|     - A `M$100` bet on `YES` with `limitProb=0.6` would fill partially or completely | ||||
|       depending on current unfilled limit bets and the AMM's liquidity. Any remaining | ||||
|       portion of the bet not filled would remain to be matched against in the future. | ||||
|   - An unfilled limit order bet can be cancelled using the cancel API. | ||||
| 
 | ||||
| Example request: | ||||
| 
 | ||||
|  | @ -581,12 +617,12 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ | |||
| 
 | ||||
| ### `POST /v0/market/[marketId]/sell` | ||||
| 
 | ||||
| Sells some quantity of shares in a market on behalf of the authorized user. | ||||
| Sells some quantity of shares in a binary market on behalf of the authorized user. | ||||
| 
 | ||||
| Parameters: | ||||
| 
 | ||||
| - `outcome`: Required. One of `YES`, `NO`, or a `number` indicating the numeric | ||||
|   bucket ID, depending on the market type. | ||||
| - `outcome`: Optional. One of `YES`, or `NO`. If you leave it off, and you only | ||||
|   own one kind of shares, you will sell that kind of shares. | ||||
| - `shares`: Optional. The amount of shares to sell of the outcome given | ||||
|   above. If not provided, all the shares you own will be sold. | ||||
| 
 | ||||
|  | @ -617,7 +653,7 @@ Requires no authorization. | |||
| 
 | ||||
| - Example request | ||||
|   ``` | ||||
|   https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-california-abolish-daylight-sa | ||||
|   https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-i-be-able-to-place-a-limit-ord | ||||
|   ``` | ||||
| - Response type: A `Bet[]`. | ||||
| 
 | ||||
|  | @ -625,31 +661,60 @@ Requires no authorization. | |||
| 
 | ||||
|   ```json | ||||
|   [ | ||||
|     // Limit bet, partially filled. | ||||
|     { | ||||
|       "probAfter": 0.44418877319153904, | ||||
|       "shares": -645.8346334931828, | ||||
|       "isFilled": false, | ||||
|       "amount": 15.596681605353808, | ||||
|       "userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2", | ||||
|       "contractId": "Tz5dA01GkK5QKiQfZeDL", | ||||
|       "probBefore": 0.5730753474948571, | ||||
|       "isCancelled": false, | ||||
|       "outcome": "YES", | ||||
|       "contractId": "tgB1XmvFXZNhjr3xMNLp", | ||||
|       "sale": { | ||||
|         "betId": "RcOtarI3d1DUUTjiE0rx", | ||||
|         "amount": 474.9999999999998 | ||||
|       }, | ||||
|       "createdTime": 1644602886293, | ||||
|       "userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2", | ||||
|       "probBefore": 0.7229189477449224, | ||||
|       "id": "x9eNmCaqQeXW8AgJ8Zmp", | ||||
|       "amount": -499.9999999999998 | ||||
|       "fees": { "creatorFee": 0, "liquidityFee": 0, "platformFee": 0 }, | ||||
|       "shares": 31.193363210707616, | ||||
|       "limitProb": 0.5, | ||||
|       "id": "yXB8lVbs86TKkhWA1FVi", | ||||
|       "loanAmount": 0, | ||||
|       "orderAmount": 100, | ||||
|       "probAfter": 0.5730753474948571, | ||||
|       "createdTime": 1659482775970, | ||||
|       "fills": [ | ||||
|         { | ||||
|           "timestamp": 1659483249648, | ||||
|           "matchedBetId": "MfrMd5HTiGASDXzqibr7", | ||||
|           "amount": 15.596681605353808, | ||||
|           "shares": 31.193363210707616 | ||||
|         } | ||||
|       ] | ||||
|     }, | ||||
|     // Normal bet (no limitProb specified). | ||||
|     { | ||||
|       "probAfter": 0.9901970375647697, | ||||
|       "contractId": "zdeaYVAfHlo9jKzWh57J", | ||||
|       "outcome": "YES", | ||||
|       "amount": 1, | ||||
|       "id": "8PqxKYwXCcLYoXy2m2Nm", | ||||
|       "shares": 1.0049875638533763, | ||||
|       "userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2", | ||||
|       "probBefore": 0.9900000000000001, | ||||
|       "createdTime": 1644705818872 | ||||
|       "shares": 17.350459904608414, | ||||
|       "probBefore": 0.5304358279113885, | ||||
|       "isFilled": true, | ||||
|       "probAfter": 0.5730753474948571, | ||||
|       "userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2", | ||||
|       "amount": 10, | ||||
|       "contractId": "Tz5dA01GkK5QKiQfZeDL", | ||||
|       "id": "1LPJHNz5oAX4K6YtJlP1", | ||||
|       "fees": { | ||||
|         "platformFee": 0, | ||||
|         "liquidityFee": 0, | ||||
|         "creatorFee": 0.4251333951457593 | ||||
|       }, | ||||
|       "isCancelled": false, | ||||
|       "loanAmount": 0, | ||||
|       "orderAmount": 10, | ||||
|       "fills": [ | ||||
|         { | ||||
|           "amount": 10, | ||||
|           "matchedBetId": null, | ||||
|           "shares": 17.350459904608414, | ||||
|           "timestamp": 1659482757271 | ||||
|         } | ||||
|       ], | ||||
|       "createdTime": 1659482757271, | ||||
|       "outcome": "YES" | ||||
|     } | ||||
|   ] | ||||
|   ``` | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ A list of community-created projects built on, or related to, Manifold Markets. | |||
| 
 | ||||
| - [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government | ||||
| - [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold | ||||
| - [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$. | ||||
| 
 | ||||
| ## API / Dev | ||||
| 
 | ||||
|  | @ -21,3 +22,4 @@ A list of community-created projects built on, or related to, Manifold Markets. | |||
| ## Bots | ||||
| 
 | ||||
| - [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon | ||||
| - [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets | ||||
|  |  | |||
|  | @ -22,7 +22,7 @@ service cloud.firestore { | |||
|       allow read; | ||||
|       allow update: if resource.data.id == request.auth.uid | ||||
|                        && request.resource.data.diff(resource.data).affectedKeys() | ||||
|                                                                     .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime']); | ||||
|                                                                     .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']); | ||||
|       // User referral rules | ||||
|       allow update: if resource.data.id == request.auth.uid | ||||
|                          && request.resource.data.diff(resource.data).affectedKeys() | ||||
|  |  | |||
|  | @ -31,6 +31,7 @@ | |||
|     "@tiptap/extension-link": "2.0.0-beta.43", | ||||
|     "@tiptap/extension-mention": "2.0.0-beta.102", | ||||
|     "@tiptap/starter-kit": "2.0.0-beta.190", | ||||
|     "dayjs": "1.11.4", | ||||
|     "cors": "2.8.5", | ||||
|     "express": "4.18.1", | ||||
|     "firebase-admin": "10.0.0", | ||||
|  |  | |||
|  | @ -14,7 +14,7 @@ import { | |||
| import { slugify } from '../../common/util/slugify' | ||||
| import { randomString } from '../../common/util/random' | ||||
| 
 | ||||
| import { chargeUser } from './utils' | ||||
| import { chargeUser, getContract } from './utils' | ||||
| import { APIError, newEndpoint, validate, zTimestamp } from './api' | ||||
| 
 | ||||
| import { | ||||
|  | @ -28,11 +28,11 @@ import { Answer, getNoneAnswer } from '../../common/answer' | |||
| import { getNewContract } from '../../common/new-contract' | ||||
| import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' | ||||
| import { User } from '../../common/user' | ||||
| import { Group, MAX_ID_LENGTH } from '../../common/group' | ||||
| import { Group, GroupLink, MAX_ID_LENGTH } from '../../common/group' | ||||
| import { getPseudoProbability } from '../../common/pseudo-numeric' | ||||
| import { JSONContent } from '@tiptap/core' | ||||
| import { zip } from 'lodash' | ||||
| import { Bet } from 'common/bet' | ||||
| import { uniq, zip } from 'lodash' | ||||
| import { Bet } from '../../common/bet' | ||||
| 
 | ||||
| const descScehma: z.ZodType<JSONContent> = z.lazy(() => | ||||
|   z.intersection( | ||||
|  | @ -136,27 +136,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => { | |||
|   const slug = await getSlug(question) | ||||
|   const contractRef = firestore.collection('contracts').doc() | ||||
| 
 | ||||
|   let group = null | ||||
|   if (groupId) { | ||||
|     const groupDocRef = firestore.collection('groups').doc(groupId) | ||||
|     const groupDoc = await groupDocRef.get() | ||||
|     if (!groupDoc.exists) { | ||||
|       throw new APIError(400, 'No group exists with the given group ID.') | ||||
|     } | ||||
| 
 | ||||
|     group = groupDoc.data() as Group | ||||
|     if (!group.memberIds.includes(user.id)) { | ||||
|       throw new APIError( | ||||
|         400, | ||||
|         'User must be a member of the group to add markets to it.' | ||||
|       ) | ||||
|     } | ||||
|     if (!group.contractIds.includes(contractRef.id)) | ||||
|       await groupDocRef.update({ | ||||
|         contractIds: [...group.contractIds, contractRef.id], | ||||
|       }) | ||||
|   } | ||||
| 
 | ||||
|   console.log( | ||||
|     'creating contract for', | ||||
|     user.username, | ||||
|  | @ -188,6 +167,33 @@ export const createmarket = newEndpoint({}, async (req, auth) => { | |||
| 
 | ||||
|   await contractRef.create(contract) | ||||
| 
 | ||||
|   let group = null | ||||
|   if (groupId) { | ||||
|     const groupDocRef = firestore.collection('groups').doc(groupId) | ||||
|     const groupDoc = await groupDocRef.get() | ||||
|     if (!groupDoc.exists) { | ||||
|       throw new APIError(400, 'No group exists with the given group ID.') | ||||
|     } | ||||
| 
 | ||||
|     group = groupDoc.data() as Group | ||||
|     if ( | ||||
|       !group.memberIds.includes(user.id) && | ||||
|       !group.anyoneCanJoin && | ||||
|       group.creatorId !== user.id | ||||
|     ) { | ||||
|       throw new APIError( | ||||
|         400, | ||||
|         'User must be a member/creator of the group or group must be open to add markets to it.' | ||||
|       ) | ||||
|     } | ||||
|     if (!group.contractIds.includes(contractRef.id)) { | ||||
|       await createGroupLinks(group, [contractRef.id], auth.uid) | ||||
|       await groupDocRef.update({ | ||||
|         contractIds: uniq([...group.contractIds, contractRef.id]), | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const providerId = user.id | ||||
| 
 | ||||
|   if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { | ||||
|  | @ -284,3 +290,38 @@ export async function getContractFromSlug(slug: string) { | |||
| 
 | ||||
|   return snap.empty ? undefined : (snap.docs[0].data() as Contract) | ||||
| } | ||||
| 
 | ||||
| async function createGroupLinks( | ||||
|   group: Group, | ||||
|   contractIds: string[], | ||||
|   userId: string | ||||
| ) { | ||||
|   for (const contractId of contractIds) { | ||||
|     const contract = await getContract(contractId) | ||||
|     if (!contract?.groupSlugs?.includes(group.slug)) { | ||||
|       await firestore | ||||
|         .collection('contracts') | ||||
|         .doc(contractId) | ||||
|         .update({ | ||||
|           groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]), | ||||
|         }) | ||||
|     } | ||||
|     if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) { | ||||
|       await firestore | ||||
|         .collection('contracts') | ||||
|         .doc(contractId) | ||||
|         .update({ | ||||
|           groupLinks: [ | ||||
|             { | ||||
|               groupId: group.id, | ||||
|               name: group.name, | ||||
|               slug: group.slug, | ||||
|               userId, | ||||
|               createdTime: Date.now(), | ||||
|             } as GroupLink, | ||||
|             ...(contract?.groupLinks ?? []), | ||||
|           ], | ||||
|         }) | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,7 @@ | |||
| import * as admin from 'firebase-admin' | ||||
| import { z } from 'zod' | ||||
| import { uniq } from 'lodash' | ||||
| 
 | ||||
| import { | ||||
|   MANIFOLD_AVATAR_URL, | ||||
|   MANIFOLD_USERNAME, | ||||
|  | @ -24,7 +26,6 @@ import { | |||
| import { track } from './analytics' | ||||
| import { APIError, newEndpoint, validate } from './api' | ||||
| import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' | ||||
| import { uniq } from 'lodash' | ||||
| import { | ||||
|   DEV_HOUSE_LIQUIDITY_PROVIDER_ID, | ||||
|   HOUSE_LIQUIDITY_PROVIDER_ID, | ||||
|  | @ -77,6 +78,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { | |||
|     creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, | ||||
|     followerCountCached: 0, | ||||
|     followedCategories: DEFAULT_CATEGORIES, | ||||
|     shouldShowWelcome: true, | ||||
|   } | ||||
| 
 | ||||
|   await firestore.collection('users').doc(auth.uid).create(user) | ||||
|  | @ -92,8 +94,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => { | |||
| 
 | ||||
|   await firestore.collection('private-users').doc(auth.uid).create(privateUser) | ||||
| 
 | ||||
|   await sendWelcomeEmail(user, privateUser) | ||||
|   await addUserToDefaultGroups(user) | ||||
|   await sendWelcomeEmail(user, privateUser) | ||||
|   await track(auth.uid, 'create user', { username }, { ip: req.ip }) | ||||
| 
 | ||||
|   return user | ||||
|  |  | |||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										738
									
								
								functions/src/email-templates/creating-market.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										738
									
								
								functions/src/email-templates/creating-market.html
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,738 @@ | |||
| <!DOCTYPE html> | ||||
| <html | ||||
|   xmlns="http://www.w3.org/1999/xhtml" | ||||
|   xmlns:v="urn:schemas-microsoft-com:vml" | ||||
|   xmlns:o="urn:schemas-microsoft-com:office:office" | ||||
| > | ||||
|   <head> | ||||
|     <title>(no subject)</title> | ||||
|     <!--[if !mso]><!--> | ||||
|     <meta http-equiv="X-UA-Compatible" content="IE=edge" /> | ||||
|     <!--<![endif]--> | ||||
|     <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> | ||||
|     <meta name="viewport" content="width=device-width,initial-scale=1" /> | ||||
|     <style type="text/css"> | ||||
|       #outlook a { | ||||
|         padding: 0; | ||||
|       } | ||||
|       body { | ||||
|         margin: 0; | ||||
|         padding: 0; | ||||
|         -webkit-text-size-adjust: 100%; | ||||
|         -ms-text-size-adjust: 100%; | ||||
|       } | ||||
|       table, | ||||
|       td { | ||||
|         border-collapse: collapse; | ||||
|         mso-table-lspace: 0pt; | ||||
|         mso-table-rspace: 0pt; | ||||
|       } | ||||
|       img { | ||||
|         border: 0; | ||||
|         height: auto; | ||||
|         line-height: 100%; | ||||
|         outline: none; | ||||
|         text-decoration: none; | ||||
|         -ms-interpolation-mode: bicubic; | ||||
|       } | ||||
|       p { | ||||
|         display: block; | ||||
|         margin: 13px 0; | ||||
|       } | ||||
|     </style> | ||||
|     <!--[if mso]> | ||||
|       <noscript> | ||||
|         <xml> | ||||
|           <o:OfficeDocumentSettings> | ||||
|             <o:AllowPNG /> | ||||
|             <o:PixelsPerInch>96</o:PixelsPerInch> | ||||
|           </o:OfficeDocumentSettings> | ||||
|         </xml> | ||||
|       </noscript> | ||||
|     <![endif]--> | ||||
|     <!--[if lte mso 11]> | ||||
|       <style type="text/css"> | ||||
|         .mj-outlook-group-fix { | ||||
|           width: 100% !important; | ||||
|         } | ||||
|       </style> | ||||
|     <![endif]--> | ||||
|     <!--[if !mso]><!--> | ||||
|     <link | ||||
|       href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" | ||||
|       rel="stylesheet" | ||||
|       type="text/css" | ||||
|     /> | ||||
|     <link | ||||
|       href="https://fonts.googleapis.com/css?family=Readex+Pro" | ||||
|       rel="stylesheet" | ||||
|       type="text/css" | ||||
|     /> | ||||
|     <link | ||||
|       href="https://fonts.googleapis.com/css?family=Readex+Pro" | ||||
|       rel="stylesheet" | ||||
|       type="text/css" | ||||
|     /> | ||||
|     <style type="text/css"> | ||||
|       @import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700); | ||||
|       @import url(https://fonts.googleapis.com/css?family=Readex+Pro); | ||||
|       @import url(https://fonts.googleapis.com/css?family=Readex+Pro); | ||||
|     </style> | ||||
|     <!--<![endif]--> | ||||
|     <style type="text/css"> | ||||
|       @media only screen and (min-width: 480px) { | ||||
|         .mj-column-per-100 { | ||||
|           width: 100% !important; | ||||
|           max-width: 100%; | ||||
|         } | ||||
|       } | ||||
|     </style> | ||||
|     <style media="screen and (min-width:480px)"> | ||||
|       .moz-text-html .mj-column-per-100 { | ||||
|         width: 100% !important; | ||||
|         max-width: 100%; | ||||
|       } | ||||
|     </style> | ||||
|     <style type="text/css"> | ||||
|       [owa] .mj-column-per-100 { | ||||
|         width: 100% !important; | ||||
|         max-width: 100%; | ||||
|       } | ||||
|     </style> | ||||
|     <style type="text/css"> | ||||
|       @media only screen and (max-width: 480px) { | ||||
|         table.mj-full-width-mobile { | ||||
|           width: 100% !important; | ||||
|         } | ||||
|         td.mj-full-width-mobile { | ||||
|           width: auto !important; | ||||
|         } | ||||
|       } | ||||
|     </style> | ||||
|   </head> | ||||
|   <body style="word-spacing: normal; background-color: #f4f4f4"> | ||||
|     <div style="background-color: #f4f4f4"> | ||||
|       <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> | ||||
|       <div | ||||
|         style=" | ||||
|           background: #ffffff; | ||||
|           background-color: #ffffff; | ||||
|           margin: 0px auto; | ||||
|           max-width: 600px; | ||||
|         " | ||||
|       > | ||||
|         <table | ||||
|           align="center" | ||||
|           border="0" | ||||
|           cellpadding="0" | ||||
|           cellspacing="0" | ||||
|           role="presentation" | ||||
|           style="background: #ffffff; background-color: #ffffff; width: 100%" | ||||
|         > | ||||
|           <tbody> | ||||
|             <tr> | ||||
|               <td | ||||
|                 style=" | ||||
|                   direction: ltr; | ||||
|                   font-size: 0px; | ||||
|                   padding: 0px 0px 0px 0px; | ||||
|                   padding-bottom: 0px; | ||||
|                   padding-left: 0px; | ||||
|                   padding-right: 0px; | ||||
|                   padding-top: 0px; | ||||
|                   text-align: center; | ||||
|                 " | ||||
|               > | ||||
|                 <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> | ||||
|                 <div | ||||
|                   class="mj-column-per-100 mj-outlook-group-fix" | ||||
|                   style=" | ||||
|                     font-size: 0px; | ||||
|                     text-align: left; | ||||
|                     direction: ltr; | ||||
|                     display: inline-block; | ||||
|                     vertical-align: top; | ||||
|                     width: 100%; | ||||
|                   " | ||||
|                 > | ||||
|                   <table | ||||
|                     border="0" | ||||
|                     cellpadding="0" | ||||
|                     cellspacing="0" | ||||
|                     role="presentation" | ||||
|                     style="vertical-align: top" | ||||
|                     width="100%" | ||||
|                   > | ||||
|                     <tbody> | ||||
|                       <tr> | ||||
|                         <td | ||||
|                           align="center" | ||||
|                           style=" | ||||
|                             font-size: 0px; | ||||
|                             padding: 0px 25px 0px 25px; | ||||
|                             padding-top: 0px; | ||||
|                             padding-right: 25px; | ||||
|                             padding-bottom: 0px; | ||||
|                             padding-left: 25px; | ||||
|                             word-break: break-word; | ||||
|                           " | ||||
|                         > | ||||
|                           <table | ||||
|                             border="0" | ||||
|                             cellpadding="0" | ||||
|                             cellspacing="0" | ||||
|                             role="presentation" | ||||
|                             style=" | ||||
|                               border-collapse: collapse; | ||||
|                               border-spacing: 0px; | ||||
|                             " | ||||
|                           > | ||||
|                             <tbody> | ||||
|                               <tr> | ||||
|                                 <td style="width: 550px"> | ||||
|                                   <a | ||||
|                                     href="https://manifold.markets/home" | ||||
|                                     target="_blank" | ||||
|                                     ><img | ||||
|                                       alt="" | ||||
|                                       height="auto" | ||||
|                                       src="https://03jlj.mjt.lu/img/03jlj/b/96u/omk8.gif" | ||||
|                                       style=" | ||||
|                                         border: none; | ||||
|                                         display: block; | ||||
|                                         outline: none; | ||||
|                                         text-decoration: none; | ||||
|                                         height: auto; | ||||
|                                         width: 100%; | ||||
|                                         font-size: 13px; | ||||
|                                       " | ||||
|                                       width="550" | ||||
|                                   /></a> | ||||
|                                 </td> | ||||
|                               </tr> | ||||
|                             </tbody> | ||||
|                           </table> | ||||
|                         </td> | ||||
|                       </tr> | ||||
|                     </tbody> | ||||
|                   </table> | ||||
|                 </div> | ||||
|                 <!--[if mso | IE]></td></tr></table><![endif]--> | ||||
|               </td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|       </div> | ||||
|       <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> | ||||
|       <div | ||||
|         style=" | ||||
|           background: #ffffff; | ||||
|           background-color: #ffffff; | ||||
|           margin: 0px auto; | ||||
|           max-width: 600px; | ||||
|         " | ||||
|       > | ||||
|         <table | ||||
|           align="center" | ||||
|           border="0" | ||||
|           cellpadding="0" | ||||
|           cellspacing="0" | ||||
|           role="presentation" | ||||
|           style="background: #ffffff; background-color: #ffffff; width: 100%" | ||||
|         > | ||||
|           <tbody> | ||||
|             <tr> | ||||
|               <td | ||||
|                 style=" | ||||
|                   direction: ltr; | ||||
|                   font-size: 0px; | ||||
|                   padding: 20px 0px 0px 0px; | ||||
|                   padding-bottom: 0px; | ||||
|                   padding-left: 0px; | ||||
|                   padding-right: 0px; | ||||
|                   padding-top: 20px; | ||||
|                   text-align: center; | ||||
|                 " | ||||
|               > | ||||
|                 <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> | ||||
|                 <div | ||||
|                   class="mj-column-per-100 mj-outlook-group-fix" | ||||
|                   style=" | ||||
|                     font-size: 0px; | ||||
|                     text-align: left; | ||||
|                     direction: ltr; | ||||
|                     display: inline-block; | ||||
|                     vertical-align: top; | ||||
|                     width: 100%; | ||||
|                   " | ||||
|                 > | ||||
|                   <table | ||||
|                     border="0" | ||||
|                     cellpadding="0" | ||||
|                     cellspacing="0" | ||||
|                     role="presentation" | ||||
|                     style="vertical-align: top" | ||||
|                     width="100%" | ||||
|                   > | ||||
|                     <tbody> | ||||
|                       <tr> | ||||
|                         <td | ||||
|                           align="left" | ||||
|                           style=" | ||||
|                             font-size: 0px; | ||||
|                             padding: 0px 25px 20px 25px; | ||||
|                             padding-top: 0px; | ||||
|                             padding-right: 25px; | ||||
|                             padding-bottom: 20px; | ||||
|                             padding-left: 25px; | ||||
|                             word-break: break-word; | ||||
|                           " | ||||
|                         > | ||||
|                           <div | ||||
|                             style=" | ||||
|                               font-family: Arial, sans-serif; | ||||
|                               font-size: 17px; | ||||
|                               letter-spacing: normal; | ||||
|                               line-height: 1; | ||||
|                               text-align: left; | ||||
|                               color: #000000; | ||||
|                             " | ||||
|                           > | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               style=" | ||||
|                                 line-height: 23px; | ||||
|                                 margin: 10px 0; | ||||
|                                 margin-top: 10px; | ||||
|                               " | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                             > | ||||
|                               <span | ||||
|                                 style=" | ||||
|                                   font-family: Readex Pro, Arial, Helvetica, | ||||
|                                     sans-serif; | ||||
|                                   font-size: 17px; | ||||
|                                 " | ||||
|                                 >On Manifold Markets, several important factors | ||||
|                                 go into making a good question. These lead to | ||||
|                                 more people betting on them and allowing a more | ||||
|                                 accurate prediction to be formed!</span | ||||
|                               > | ||||
|                             </p> | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               style="line-height: 23px; margin: 10px 0" | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                             > | ||||
|                                 | ||||
|                             </p> | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               style="line-height: 23px; margin: 10px 0" | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                             > | ||||
|                               <span | ||||
|                                 style=" | ||||
|                                   font-family: Readex Pro, Arial, Helvetica, | ||||
|                                     sans-serif; | ||||
|                                   font-size: 17px; | ||||
|                                 " | ||||
|                                 >Manifold also gives its creators 10 Mana for | ||||
|                                 each unique trader that bets on your | ||||
|                                 market!</span | ||||
|                               > | ||||
|                             </p> | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               style="line-height: 23px; margin: 10px 0" | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                             > | ||||
|                                 | ||||
|                             </p> | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               style="line-height: 23px; margin: 10px 0" | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                             > | ||||
|                               <span | ||||
|                                 style=" | ||||
|                                   color: #292fd7; | ||||
|                                   font-family: Readex Pro, Arial, Helvetica, | ||||
|                                     sans-serif; | ||||
|                                   font-size: 20px; | ||||
|                                 " | ||||
|                                 ><b>What makes a good question?</b></span | ||||
|                               > | ||||
|                             </p> | ||||
|                             <ul> | ||||
|                               <li style="line-height: 23px"> | ||||
|                                 <span | ||||
|                                   style=" | ||||
|                                     font-family: Readex Pro, Arial, Helvetica, | ||||
|                                       sans-serif; | ||||
|                                     font-size: 17px; | ||||
|                                   " | ||||
|                                   ><b>Clear resolution criteria. </b>This is | ||||
|                                   needed so users know how you are going to | ||||
|                                   decide on what the correct answer is.</span | ||||
|                                 > | ||||
|                               </li> | ||||
|                               <li style="line-height: 23px"> | ||||
|                                 <span | ||||
|                                   style=" | ||||
|                                     font-family: Readex Pro, Arial, Helvetica, | ||||
|                                       sans-serif; | ||||
|                                     font-size: 17px; | ||||
|                                   " | ||||
|                                   ><b>Clear resolution date</b>. This is | ||||
|                                   sometimes slightly different from the closing | ||||
|                                   date. We recommend leaving the market open up | ||||
|                                   until you resolve it, but if it is different | ||||
|                                   make sure you say what day you intend to | ||||
|                                   resolve it in the description!</span | ||||
|                                 > | ||||
|                               </li> | ||||
|                               <li style="line-height: 23px"> | ||||
|                                 <span | ||||
|                                   style=" | ||||
|                                     font-family: Readex Pro, Arial, Helvetica, | ||||
|                                       sans-serif; | ||||
|                                     font-size: 17px; | ||||
|                                   " | ||||
|                                   ><b>Detailed description. </b>Use the rich | ||||
|                                   text editor to create an easy to read | ||||
|                                   description. Include any context or background | ||||
|                                   information that could be useful to people who | ||||
|                                   are interested in learning more that are | ||||
|                                   uneducated on the subject.</span | ||||
|                                 > | ||||
|                               </li> | ||||
|                               <li style="line-height: 23px"> | ||||
|                                 <span | ||||
|                                   style=" | ||||
|                                     font-family: Readex Pro, Arial, Helvetica, | ||||
|                                       sans-serif; | ||||
|                                     font-size: 17px; | ||||
|                                   " | ||||
|                                   ><b>Add it to a group. </b>Groups are the | ||||
|                                   primary way users filter for relevant markets. | ||||
|                                   Also, consider making your own groups and | ||||
|                                   inviting friends/interested communities to | ||||
|                                   them from other sites!</span | ||||
|                                 > | ||||
|                               </li> | ||||
|                               <li style="line-height: 23px"> | ||||
|                                 <span | ||||
|                                   style=" | ||||
|                                     font-family: Readex Pro, Arial, Helvetica, | ||||
|                                       sans-serif; | ||||
|                                     font-size: 17px; | ||||
|                                   " | ||||
|                                   ><b>Bonus: </b>Add a comment on your | ||||
|                                   prediction and explain (with links and | ||||
|                                   sources) supporting it.</span | ||||
|                                 > | ||||
|                               </li> | ||||
|                             </ul> | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               style="line-height: 23px; margin: 10px 0" | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                             > | ||||
|                                 | ||||
|                             </p> | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               style="line-height: 23px; margin: 10px 0" | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                             > | ||||
|                               <span | ||||
|                                 style=" | ||||
|                                   color: #292fd7; | ||||
|                                   font-family: Readex Pro, Arial, Helvetica, | ||||
|                                     sans-serif; | ||||
|                                   font-size: 20px; | ||||
|                                 " | ||||
|                                 ><b | ||||
|                                   >Examples of markets you should | ||||
|                                   emulate! </b | ||||
|                                 ></span | ||||
|                               > | ||||
|                             </p> | ||||
|                             <ul> | ||||
|                               <li style="line-height: 23px"> | ||||
|                                 <a | ||||
|                                   class="link-build-content" | ||||
|                                   style="color: inherit; text-decoration: none" | ||||
|                                   target="_blank" | ||||
|                                   href="https://manifold.markets/DavidChee/will-our-upcoming-twitch-bot-be-a-s" | ||||
|                                   ><span | ||||
|                                     style=" | ||||
|                                       color: #55575d; | ||||
|                                       font-family: Readex Pro, Arial, Helvetica, | ||||
|                                         sans-serif; | ||||
|                                       font-size: 17px; | ||||
|                                     " | ||||
|                                     ><u>This complex market</u></span | ||||
|                                   ></a | ||||
|                                 ><span | ||||
|                                   style=" | ||||
|                                     font-family: Readex Pro, Arial, Helvetica, | ||||
|                                       sans-serif; | ||||
|                                     font-size: 17px; | ||||
|                                   " | ||||
|                                 > | ||||
|                                   about the project I am working on.</span | ||||
|                                 > | ||||
|                               </li> | ||||
|                               <li style="line-height: 23px"> | ||||
|                                 <a | ||||
|                                   class="link-build-content" | ||||
|                                   style="color: inherit; text-decoration: none" | ||||
|                                   target="_blank" | ||||
|                                   href="https://manifold.markets/SneakySly/will-manifold-reach-1000-weekly-act" | ||||
|                                   ><span | ||||
|                                     style=" | ||||
|                                       color: #55575d; | ||||
|                                       font-family: Readex Pro, Arial, Helvetica, | ||||
|                                         sans-serif; | ||||
|                                       font-size: 17px; | ||||
|                                     " | ||||
|                                     ><u>This simple market</u></span | ||||
|                                   ></a | ||||
|                                 ><span | ||||
|                                   style=" | ||||
|                                     font-family: Readex Pro, Arial, Helvetica, | ||||
|                                       sans-serif; | ||||
|                                     font-size: 17px; | ||||
|                                   " | ||||
|                                 > | ||||
|                                   about Manifold's weekly active | ||||
|                                   users.</span | ||||
|                                 > | ||||
|                               </li> | ||||
|                             </ul> | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               style="line-height: 23px; margin: 10px 0" | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                             > | ||||
|                                 | ||||
|                             </p> | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               style="line-height: 23px; margin: 10px 0" | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                             > | ||||
|                               <span | ||||
|                                 style=" | ||||
|                                   color: #000000; | ||||
|                                   font-family: Readex Pro, Arial, Helvetica, | ||||
|                                     sans-serif; | ||||
|                                   font-size: 17px; | ||||
|                                 " | ||||
|                                 >Why not </span> | ||||
|                                  | ||||
|                                 | ||||
|                                 | ||||
|                                 <a | ||||
|                                   class="link-build-content" | ||||
|                                   style="color: inherit; text-decoration: none" | ||||
|                                   target="_blank" | ||||
|                                   href="https://manifold.markets/create" | ||||
|                                   ><span | ||||
|                                     style=" | ||||
|                                       color: #55575d; | ||||
|                                       font-family: Readex Pro, Arial, Helvetica, | ||||
|                                         sans-serif; | ||||
|                                       font-size: 17px; | ||||
|                                     " | ||||
|                                     ><u>create a market</u></span | ||||
|                                   ></a | ||||
|                                 ><span | ||||
|                                   style=" | ||||
|                                     font-family: Readex Pro, Arial, Helvetica, | ||||
|                                       sans-serif; | ||||
|                                     font-size: 17px; | ||||
|                                   " | ||||
|                                 > | ||||
|                               while it is still fresh on your mind? | ||||
|                             </p> | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               style="line-height: 23px; margin: 10px 0" | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                             > | ||||
|                               <span | ||||
|                                 style=" | ||||
|                                   color: #000000; | ||||
|                                   font-family: Readex Pro, Arial, Helvetica, | ||||
|                                     sans-serif; | ||||
|                                   font-size: 17px; | ||||
|                                 " | ||||
|                                 >Thanks for reading!</span | ||||
|                               > | ||||
|                             </p> | ||||
|                             <p | ||||
|                               class="text-build-content" | ||||
|                               style=" | ||||
|                                 line-height: 23px; | ||||
|                                 margin: 10px 0; | ||||
|                                 margin-bottom: 10px; | ||||
|                               " | ||||
|                               data-testid="3Q8BP69fq" | ||||
|                             > | ||||
|                               <span | ||||
|                                 style=" | ||||
|                                   color: #000000; | ||||
|                                   font-family: Readex Pro, Arial, Helvetica, | ||||
|                                     sans-serif; | ||||
|                                   font-size: 17px; | ||||
|                                 " | ||||
|                                 >David from Manifold</span | ||||
|                               > | ||||
|                             </p> | ||||
|                           </div> | ||||
|                         </td> | ||||
|                       </tr> | ||||
|                     </tbody> | ||||
|                   </table> | ||||
|                 </div> | ||||
|                 <!--[if mso | IE]></td></tr></table><![endif]--> | ||||
|               </td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|       </div> | ||||
|       <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> | ||||
|       <div style="margin: 0px auto; max-width: 600px"> | ||||
|         <table | ||||
|           align="center" | ||||
|           border="0" | ||||
|           cellpadding="0" | ||||
|           cellspacing="0" | ||||
|           role="presentation" | ||||
|           style="width: 100%" | ||||
|         > | ||||
|           <tbody> | ||||
|             <tr> | ||||
|               <td | ||||
|                 style=" | ||||
|                   direction: ltr; | ||||
|                   font-size: 0px; | ||||
|                   padding: 0 0 20px 0; | ||||
|                   text-align: center; | ||||
|                 " | ||||
|               > | ||||
|                 | ||||
|       <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> | ||||
|       <div style="margin: 0px auto; max-width: 600px"> | ||||
|         <table | ||||
|           align="center" | ||||
|           border="0" | ||||
|           cellpadding="0" | ||||
|           cellspacing="0" | ||||
|           role="presentation" | ||||
|           style="width: 100%" | ||||
|         > | ||||
|           <tbody> | ||||
|             <tr> | ||||
|               <td | ||||
|                 style=" | ||||
|                   direction: ltr; | ||||
|                   font-size: 0px; | ||||
|                   padding: 20px 0px 20px 0px; | ||||
|                   text-align: center; | ||||
|                 " | ||||
|               > | ||||
|                 <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> | ||||
|                 <div | ||||
|                   class="mj-column-per-100 mj-outlook-group-fix" | ||||
|                   style=" | ||||
|                     font-size: 0px; | ||||
|                     text-align: left; | ||||
|                     direction: ltr; | ||||
|                     display: inline-block; | ||||
|                     vertical-align: top; | ||||
|                     width: 100%; | ||||
|                   " | ||||
|                 > | ||||
|                   <table | ||||
|                     border="0" | ||||
|                     cellpadding="0" | ||||
|                     cellspacing="0" | ||||
|                     role="presentation" | ||||
|                     width="100%" | ||||
|                   > | ||||
|                     <tbody> | ||||
|                       <tr> | ||||
|                         <td style="vertical-align: top; padding: 0"> | ||||
|                           <table | ||||
|                             border="0" | ||||
|                             cellpadding="0" | ||||
|                             cellspacing="0" | ||||
|                             role="presentation" | ||||
|                             width="100%" | ||||
|                           > | ||||
|                             <tbody> | ||||
|                               <tr> | ||||
|                                 <td | ||||
|                                   align="center" | ||||
|                                   style=" | ||||
|                                     font-size: 0px; | ||||
|                                     padding: 10px 25px; | ||||
|                                     word-break: break-word; | ||||
|                                   " | ||||
|                                 > | ||||
|                                   <div | ||||
|                                     style=" | ||||
|                                       font-family: Ubuntu, Helvetica, Arial, | ||||
|                                         sans-serif; | ||||
|                                       font-size: 11px; | ||||
|                                       line-height: 22px; | ||||
|                                       text-align: center; | ||||
|                                       color: #000000; | ||||
|                                     " | ||||
|                                   > | ||||
|                                     <p style="margin: 10px 0"> | ||||
|                                       This e-mail has been sent to {{name}}, | ||||
|                                       <a | ||||
|                                         href="{{unsubscribeLink}}" | ||||
|                                         style=" | ||||
|                                           color: inherit; | ||||
|                                           text-decoration: none; | ||||
|                                         " | ||||
|                                         target="_blank" | ||||
|                                         >click here to unsubscribe</a | ||||
|                                       >. | ||||
|                                     </p> | ||||
|                                   </div> | ||||
|                                 </td> | ||||
|                               </tr> | ||||
|                               <tr> | ||||
|                                 <td | ||||
|                                   align="center" | ||||
|                                   style=" | ||||
|                                     font-size: 0px; | ||||
|                                     padding: 10px 25px; | ||||
|                                     word-break: break-word; | ||||
|                                   " | ||||
|                                 ></td> | ||||
|                               </tr> | ||||
|                             </tbody> | ||||
|                           </table> | ||||
|                         </td> | ||||
|                       </tr> | ||||
|                     </tbody> | ||||
|                   </table> | ||||
|                 </div> | ||||
|                 <!--[if mso | IE]></td></tr></table><![endif]--> | ||||
|               </td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|       </div> | ||||
|       <!--[if mso | IE]></td></tr></table><![endif]--> | ||||
|     </div> | ||||
|   </body> | ||||
| </html> | ||||
|  | @ -165,7 +165,6 @@ export const sendWelcomeEmail = async ( | |||
|   ) | ||||
| } | ||||
| 
 | ||||
| // TODO: use manalinks to give out M$500
 | ||||
| export const sendOneWeekBonusEmail = async ( | ||||
|   user: User, | ||||
|   privateUser: PrivateUser | ||||
|  | @ -185,12 +184,12 @@ export const sendOneWeekBonusEmail = async ( | |||
| 
 | ||||
|   await sendTemplateEmail( | ||||
|     privateUser.email, | ||||
|     'Manifold one week anniversary gift', | ||||
|     'Manifold Markets one week anniversary gift', | ||||
|     'one-week', | ||||
|     { | ||||
|       name: firstName, | ||||
|       unsubscribeLink, | ||||
|       manalink: '', // TODO
 | ||||
|       manalink: 'https://manifold.markets/link/lj4JbBvE', | ||||
|     }, | ||||
|     { | ||||
|       from: 'David from Manifold <david@manifold.markets>', | ||||
|  |  | |||
							
								
								
									
										18
									
								
								functions/src/get-current-user.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								functions/src/get-current-user.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| import { User } from 'common/user' | ||||
| import * as admin from 'firebase-admin' | ||||
| import { newEndpoint, APIError } from './api' | ||||
| 
 | ||||
| export const getcurrentuser = newEndpoint( | ||||
|   { method: 'GET' }, | ||||
|   async (_req, auth) => { | ||||
|     const userDoc = firestore.doc(`users/${auth.uid}`) | ||||
|     const [userSnap] = await firestore.getAll(userDoc) | ||||
|     if (!userSnap.exists) throw new APIError(400, 'User not found.') | ||||
| 
 | ||||
|     const user = userSnap.data() as User | ||||
| 
 | ||||
|     return user | ||||
|   } | ||||
| ) | ||||
| 
 | ||||
| const firestore = admin.firestore() | ||||
|  | @ -27,6 +27,25 @@ export * from './on-delete-group' | |||
| export * from './score-contracts' | ||||
| 
 | ||||
| // v2
 | ||||
| export * from './health' | ||||
| export * from './transact' | ||||
| export * from './change-user-info' | ||||
| export * from './create-user' | ||||
| export * from './create-answer' | ||||
| export * from './place-bet' | ||||
| export * from './cancel-bet' | ||||
| export * from './sell-bet' | ||||
| export * from './sell-shares' | ||||
| export * from './claim-manalink' | ||||
| export * from './create-contract' | ||||
| export * from './add-liquidity' | ||||
| export * from './withdraw-liquidity' | ||||
| export * from './create-group' | ||||
| export * from './resolve-market' | ||||
| export * from './unsubscribe' | ||||
| export * from './stripe' | ||||
| export * from './mana-bonus-email' | ||||
| 
 | ||||
| import { health } from './health' | ||||
| import { transact } from './transact' | ||||
| import { changeuserinfo } from './change-user-info' | ||||
|  | @ -44,6 +63,7 @@ import { creategroup } from './create-group' | |||
| import { resolvemarket } from './resolve-market' | ||||
| import { unsubscribe } from './unsubscribe' | ||||
| import { stripewebhook, createcheckoutsession } from './stripe' | ||||
| import { getcurrentuser } from './get-current-user' | ||||
| 
 | ||||
| const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { | ||||
|   return onRequest(opts, handler as any) | ||||
|  | @ -66,6 +86,7 @@ const resolveMarketFunction = toCloudFunction(resolvemarket) | |||
| const unsubscribeFunction = toCloudFunction(unsubscribe) | ||||
| const stripeWebhookFunction = toCloudFunction(stripewebhook) | ||||
| const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) | ||||
| const getCurrentUserFunction = toCloudFunction(getcurrentuser) | ||||
| 
 | ||||
| export { | ||||
|   healthFunction as health, | ||||
|  | @ -86,4 +107,5 @@ export { | |||
|   unsubscribeFunction as unsubscribe, | ||||
|   stripeWebhookFunction as stripewebhook, | ||||
|   createCheckoutSessionFunction as createcheckoutsession, | ||||
|   getCurrentUserFunction as getcurrentuser, | ||||
| } | ||||
|  |  | |||
							
								
								
									
										42
									
								
								functions/src/mana-bonus-email.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								functions/src/mana-bonus-email.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | |||
| import * as functions from 'firebase-functions' | ||||
| import * as admin from 'firebase-admin' | ||||
| import * as dayjs from 'dayjs' | ||||
| 
 | ||||
| import { getPrivateUser } from './utils' | ||||
| import { sendOneWeekBonusEmail } from './emails' | ||||
| import { User } from 'common/user' | ||||
| 
 | ||||
| export const manabonusemail = functions | ||||
|   .runWith({ secrets: ['MAILGUN_KEY'] }) | ||||
|   .pubsub.schedule('0 9 * * 1-7') | ||||
|   .onRun(async () => { | ||||
|     await sendOneWeekEmails() | ||||
|   }) | ||||
| 
 | ||||
| const firestore = admin.firestore() | ||||
| 
 | ||||
| async function sendOneWeekEmails() { | ||||
|   const oneWeekAgo = dayjs().subtract(1, 'week').valueOf() | ||||
|   const twoWeekAgo = dayjs().subtract(2, 'weeks').valueOf() | ||||
| 
 | ||||
|   const userDocs = await firestore | ||||
|     .collection('users') | ||||
|     .where('createdTime', '<=', oneWeekAgo) | ||||
|     .get() | ||||
| 
 | ||||
|   for (const user of userDocs.docs.map((d) => d.data() as User)) { | ||||
|     if (user.createdTime < twoWeekAgo) continue | ||||
| 
 | ||||
|     const privateUser = await getPrivateUser(user.id) | ||||
|     if (!privateUser || privateUser.manaBonusEmailSent) continue | ||||
| 
 | ||||
|     await firestore | ||||
|       .collection('private-users') | ||||
|       .doc(user.id) | ||||
|       .update({ manaBonusEmailSent: true }) | ||||
| 
 | ||||
|     console.log('sending m$ bonus email to', user.username) | ||||
|     await sendOneWeekBonusEmail(user, privateUser) | ||||
|     return | ||||
|   } | ||||
| } | ||||
|  | @ -1,6 +1,8 @@ | |||
| import * as functions from 'firebase-functions' | ||||
| import * as admin from 'firebase-admin' | ||||
| import { Group } from '../../common/group' | ||||
| import { getContract } from './utils' | ||||
| import { uniq } from 'lodash' | ||||
| const firestore = admin.firestore() | ||||
| 
 | ||||
| export const onUpdateGroup = functions.firestore | ||||
|  | @ -9,7 +11,7 @@ export const onUpdateGroup = functions.firestore | |||
|     const prevGroup = change.before.data() as Group | ||||
|     const group = change.after.data() as Group | ||||
| 
 | ||||
|     // ignore the update we just made
 | ||||
|     // Ignore the activity update we just made
 | ||||
|     if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) | ||||
|       return | ||||
| 
 | ||||
|  | @ -27,3 +29,23 @@ export const onUpdateGroup = functions.firestore | |||
|       .doc(group.id) | ||||
|       .update({ mostRecentActivityTime: Date.now() }) | ||||
|   }) | ||||
| 
 | ||||
| export async function removeGroupLinks(group: Group, contractIds: string[]) { | ||||
|   for (const contractId of contractIds) { | ||||
|     const contract = await getContract(contractId) | ||||
|     await firestore | ||||
|       .collection('contracts') | ||||
|       .doc(contractId) | ||||
|       .update({ | ||||
|         groupSlugs: uniq([ | ||||
|           ...(contract?.groupSlugs?.filter((slug) => slug !== group.slug) ?? | ||||
|             []), | ||||
|         ]), | ||||
|         groupLinks: [ | ||||
|           ...(contract?.groupLinks?.filter( | ||||
|             (link) => link.groupId !== group.id | ||||
|           ) ?? []), | ||||
|         ], | ||||
|       }) | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -18,6 +18,7 @@ import { | |||
|   groupPayoutsByUser, | ||||
|   Payout, | ||||
| } from '../../common/payouts' | ||||
| import { isAdmin } from '../../common/envs/constants' | ||||
| import { removeUndefinedProps } from '../../common/util/object' | ||||
| import { LiquidityProvision } from '../../common/liquidity-provision' | ||||
| import { APIError, newEndpoint, validate } from './api' | ||||
|  | @ -69,8 +70,6 @@ const opts = { secrets: ['MAILGUN_KEY'] } | |||
| 
 | ||||
| export const resolvemarket = newEndpoint(opts, async (req, auth) => { | ||||
|   const { contractId } = validate(bodySchema, req.body) | ||||
|   const userId = auth.uid | ||||
| 
 | ||||
|   const contractDoc = firestore.doc(`contracts/${contractId}`) | ||||
|   const contractSnap = await contractDoc.get() | ||||
|   if (!contractSnap.exists) | ||||
|  | @ -83,7 +82,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { | |||
|     req.body | ||||
|   ) | ||||
| 
 | ||||
|   if (creatorId !== userId) | ||||
|   if (creatorId !== auth.uid && !isAdmin(auth.uid)) | ||||
|     throw new APIError(403, 'User is not creator of contract') | ||||
| 
 | ||||
|   if (contract.resolution) throw new APIError(400, 'Contract already resolved') | ||||
|  |  | |||
							
								
								
									
										25
									
								
								functions/src/scripts/backfill-group-ids.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								functions/src/scripts/backfill-group-ids.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | |||
| // We have some groups without IDs. Let's fill them in.
 | ||||
| 
 | ||||
| import * as admin from 'firebase-admin' | ||||
| import { initAdmin } from './script-init' | ||||
| import { log, writeAsync } from '../utils' | ||||
| 
 | ||||
| initAdmin() | ||||
| const firestore = admin.firestore() | ||||
| 
 | ||||
| if (require.main === module) { | ||||
|   const groupsQuery = firestore.collection('groups') | ||||
|   groupsQuery.get().then(async (groupSnaps) => { | ||||
|     log(`Loaded ${groupSnaps.size} groups.`) | ||||
|     const needsFilling = groupSnaps.docs.filter((ct) => { | ||||
|       return !('id' in ct.data()) | ||||
|     }) | ||||
|     log(`${needsFilling.length} groups need IDs.`) | ||||
|     const updates = needsFilling.map((group) => { | ||||
|       return { doc: group.ref, fields: { id: group.id } } | ||||
|     }) | ||||
|     log(`Updating ${updates.length} groups.`) | ||||
|     await writeAsync(firestore, updates) | ||||
|     log(`Updated all groups.`) | ||||
|   }) | ||||
| } | ||||
|  | @ -1,4 +1,4 @@ | |||
| import { sumBy, uniq } from 'lodash' | ||||
| import { mapValues, groupBy, sumBy, uniq } from 'lodash' | ||||
| import * as admin from 'firebase-admin' | ||||
| import { z } from 'zod' | ||||
| 
 | ||||
|  | @ -9,7 +9,7 @@ import { getCpmmSellBetInfo } from '../../common/sell-bet' | |||
| import { addObjects, removeUndefinedProps } from '../../common/util/object' | ||||
| import { getValues, log } from './utils' | ||||
| import { Bet } from '../../common/bet' | ||||
| import { floatingLesserEqual } from '../../common/util/math' | ||||
| import { floatingEqual, floatingLesserEqual } from '../../common/util/math' | ||||
| import { getUnfilledBetsQuery, updateMakers } from './place-bet' | ||||
| import { FieldValue } from 'firebase-admin/firestore' | ||||
| import { redeemShares } from './redeem-shares' | ||||
|  | @ -17,7 +17,7 @@ import { redeemShares } from './redeem-shares' | |||
| const bodySchema = z.object({ | ||||
|   contractId: z.string(), | ||||
|   shares: z.number().optional(), // leave it out to sell all shares
 | ||||
|   outcome: z.enum(['YES', 'NO']), | ||||
|   outcome: z.enum(['YES', 'NO']).optional(), // leave it out to sell whichever you have
 | ||||
| }) | ||||
| 
 | ||||
| export const sellshares = newEndpoint({}, async (req, auth) => { | ||||
|  | @ -46,9 +46,31 @@ export const sellshares = newEndpoint({}, async (req, auth) => { | |||
|       throw new APIError(400, 'Trading is closed.') | ||||
| 
 | ||||
|     const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0) | ||||
|     const betsByOutcome = groupBy(userBets, (bet) => bet.outcome) | ||||
|     const sharesByOutcome = mapValues(betsByOutcome, (bets) => | ||||
|       sumBy(bets, (b) => b.shares) | ||||
|     ) | ||||
| 
 | ||||
|     const outcomeBets = userBets.filter((bet) => bet.outcome == outcome) | ||||
|     const maxShares = sumBy(outcomeBets, (bet) => bet.shares) | ||||
|     let chosenOutcome: 'YES' | 'NO' | ||||
|     if (outcome != null) { | ||||
|       chosenOutcome = outcome | ||||
|     } else { | ||||
|       const nonzeroShares = Object.entries(sharesByOutcome).filter( | ||||
|         ([_k, v]) => !floatingEqual(0, v) | ||||
|       ) | ||||
|       if (nonzeroShares.length == 0) { | ||||
|         throw new APIError(400, "You don't own any shares in this market.") | ||||
|       } | ||||
|       if (nonzeroShares.length > 1) { | ||||
|         throw new APIError( | ||||
|           400, | ||||
|           `You own multiple kinds of shares, but did not specify which to sell.` | ||||
|         ) | ||||
|       } | ||||
|       chosenOutcome = nonzeroShares[0][0] as 'YES' | 'NO' | ||||
|     } | ||||
| 
 | ||||
|     const maxShares = sharesByOutcome[chosenOutcome] | ||||
|     const sharesToSell = shares ?? maxShares | ||||
| 
 | ||||
|     if (!floatingLesserEqual(sharesToSell, maxShares)) | ||||
|  | @ -63,7 +85,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => { | |||
| 
 | ||||
|     const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( | ||||
|       soldShares, | ||||
|       outcome, | ||||
|       chosenOutcome, | ||||
|       contract, | ||||
|       prevLoanAmount, | ||||
|       unfilledBets | ||||
|  |  | |||
|  | @ -26,9 +26,10 @@ export const sendTemplateEmail = ( | |||
|   subject: string, | ||||
|   templateId: string, | ||||
|   templateData: Record<string, string>, | ||||
|   options?: { from: string } | ||||
|   options?: Partial<mailgun.messages.SendTemplateData> | ||||
| ) => { | ||||
|   const data = { | ||||
|   const data: mailgun.messages.SendTemplateData = { | ||||
|     ...options, | ||||
|     from: options?.from ?? 'Manifold Markets <info@manifold.markets>', | ||||
|     to, | ||||
|     subject, | ||||
|  | @ -36,6 +37,7 @@ export const sendTemplateEmail = ( | |||
|     'h:X-Mailgun-Variables': JSON.stringify(templateData), | ||||
|   } | ||||
|   const mg = initMailgun() | ||||
| 
 | ||||
|   return mg.messages().send(data, (error) => { | ||||
|     if (error) console.log('Error sending email', error) | ||||
|     else console.log('Sent template email', templateId, to, subject) | ||||
|  |  | |||
|  | @ -25,6 +25,7 @@ import { creategroup } from './create-group' | |||
| import { resolvemarket } from './resolve-market' | ||||
| import { unsubscribe } from './unsubscribe' | ||||
| import { stripewebhook, createcheckoutsession } from './stripe' | ||||
| import { getcurrentuser } from './get-current-user' | ||||
| 
 | ||||
| type Middleware = (req: Request, res: Response, next: NextFunction) => void | ||||
| const app = express() | ||||
|  | @ -62,6 +63,7 @@ addJsonEndpointRoute('/creategroup', creategroup) | |||
| addJsonEndpointRoute('/resolvemarket', resolvemarket) | ||||
| addJsonEndpointRoute('/unsubscribe', unsubscribe) | ||||
| addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) | ||||
| addJsonEndpointRoute('/getcurrentuser', getcurrentuser) | ||||
| addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) | ||||
| 
 | ||||
| app.listen(PORT) | ||||
|  |  | |||
|  | @ -157,9 +157,7 @@ export function BetsList(props: { | |||
|     (c) => contractsMetrics[c.id].netPayout | ||||
|   ) | ||||
| 
 | ||||
|   const totalPortfolio = currentNetInvestment + user.balance | ||||
| 
 | ||||
|   const totalPnl = totalPortfolio - user.totalDeposits | ||||
|   const totalPnl = user.profitCached.allTime | ||||
|   const totalProfitPercent = (totalPnl / user.totalDeposits) * 100 | ||||
|   const investedProfitPercent = | ||||
|     ((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100 | ||||
|  | @ -354,7 +352,7 @@ function ContractBets(props: { | |||
|               <LimitOrderTable | ||||
|                 contract={contract} | ||||
|                 limitBets={limitBets} | ||||
|                 isYou={true} | ||||
|                 isYou={isYourBets} | ||||
|               /> | ||||
|             </div> | ||||
|           )} | ||||
|  |  | |||
|  | @ -39,8 +39,10 @@ export function Button(props: { | |||
|         color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', | ||||
|         color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', | ||||
|         color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', | ||||
|         color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200', | ||||
|         color === 'gray-white' && 'bg-white text-gray-500 hover:bg-gray-200', | ||||
|         color === 'gray' && | ||||
|           'bg-greyscale-1 text-greyscale-7 hover:bg-greyscale-2', | ||||
|         color === 'gray-white' && | ||||
|           'text-greyscale-6 hover:bg-greyscale-2 bg-white', | ||||
|         className | ||||
|       )} | ||||
|       disabled={disabled} | ||||
|  |  | |||
|  | @ -15,8 +15,8 @@ export function PillButton(props: { | |||
|       className={clsx( | ||||
|         'cursor-pointer select-none whitespace-nowrap rounded-full', | ||||
|         selected | ||||
|           ? ['text-white', color ?? 'bg-gray-700'] | ||||
|           : 'bg-gray-100 hover:bg-gray-200', | ||||
|           ? ['text-white', color ?? 'bg-greyscale-6'] | ||||
|           : 'bg-greyscale-2 hover:bg-greyscale-3', | ||||
|         big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm' | ||||
|       )} | ||||
|       onClick={onSelect} | ||||
|  |  | |||
|  | @ -1,26 +1,14 @@ | |||
| /* eslint-disable react-hooks/exhaustive-deps */ | ||||
| import algoliasearch from 'algoliasearch/lite' | ||||
| import { | ||||
|   Configure, | ||||
|   InstantSearch, | ||||
|   SearchBox, | ||||
|   SortBy, | ||||
|   useInfiniteHits, | ||||
|   useSortBy, | ||||
| } from 'react-instantsearch-hooks-web' | ||||
| 
 | ||||
| import { Contract } from 'common/contract' | ||||
| import { | ||||
|   Sort, | ||||
|   useInitialQueryAndSort, | ||||
|   useUpdateQueryAndSort, | ||||
| } from '../hooks/use-sort-and-query-params' | ||||
| import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params' | ||||
| import { | ||||
|   ContractHighlightOptions, | ||||
|   ContractsGrid, | ||||
| } from './contract/contracts-list' | ||||
| import { Row } from './layout/row' | ||||
| import { useEffect, useMemo, useRef, useState } from 'react' | ||||
| import { useEffect, useMemo, useState } from 'react' | ||||
| import { Spacer } from './layout/spacer' | ||||
| import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' | ||||
| import { useUser } from 'web/hooks/use-user' | ||||
|  | @ -30,8 +18,9 @@ import ContractSearchFirestore from 'web/pages/contract-search-firestore' | |||
| import { useMemberGroups } from 'web/hooks/use-group' | ||||
| import { Group, NEW_USER_GROUP_SLUGS } from 'common/group' | ||||
| import { PillButton } from './buttons/pill-button' | ||||
| import { sortBy } from 'lodash' | ||||
| import { range, sortBy } from 'lodash' | ||||
| import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' | ||||
| import { Col } from './layout/col' | ||||
| 
 | ||||
| const searchClient = algoliasearch( | ||||
|   'GJQPAYENIF', | ||||
|  | @ -39,17 +28,17 @@ const searchClient = algoliasearch( | |||
| ) | ||||
| 
 | ||||
| const indexPrefix = ENV === 'DEV' ? 'dev-' : '' | ||||
| const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex' | ||||
| 
 | ||||
| const sortIndexes = [ | ||||
|   { label: 'Newest', value: indexPrefix + 'contracts-newest' }, | ||||
|   // { label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
 | ||||
|   { label: 'Most popular', value: indexPrefix + 'contracts-score' }, | ||||
|   { label: 'Most traded', value: indexPrefix + 'contracts-most-traded' }, | ||||
|   { label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' }, | ||||
|   { label: 'Last updated', value: indexPrefix + 'contracts-last-updated' }, | ||||
|   { label: 'Subsidy', value: indexPrefix + 'contracts-liquidity' }, | ||||
|   { label: 'Close date', value: indexPrefix + 'contracts-close-date' }, | ||||
|   { label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' }, | ||||
| const sortOptions = [ | ||||
|   { label: 'Newest', value: 'newest' }, | ||||
|   { label: 'Trending', value: 'score' }, | ||||
|   { label: 'Most traded', value: 'most-traded' }, | ||||
|   { label: '24h volume', value: '24-hour-vol' }, | ||||
|   { label: 'Last updated', value: 'last-updated' }, | ||||
|   { label: 'Subsidy', value: 'liquidity' }, | ||||
|   { label: 'Close date', value: 'close-date' }, | ||||
|   { label: 'Resolve date', value: 'resolve-date' }, | ||||
| ] | ||||
| export const DEFAULT_SORT = 'score' | ||||
| 
 | ||||
|  | @ -108,77 +97,154 @@ export function ContractSearch(props: { | |||
|     memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups | ||||
| 
 | ||||
|   const follows = useFollows(user?.id) | ||||
|   const { initialSort } = useInitialQueryAndSort(querySortOptions) | ||||
| 
 | ||||
|   const sort = sortIndexes | ||||
|     .map(({ value }) => value) | ||||
|     .includes(`${indexPrefix}contracts-${initialSort ?? ''}`) | ||||
|     ? initialSort | ||||
|     : querySortOptions?.defaultSort ?? DEFAULT_SORT | ||||
|   const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {} | ||||
|   const { query, setQuery, sort, setSort } = useQueryAndSortParams({ | ||||
|     defaultSort, | ||||
|     shouldLoadFromStorage, | ||||
|   }) | ||||
| 
 | ||||
|   const [filter, setFilter] = useState<filter>( | ||||
|     querySortOptions?.defaultFilter ?? 'open' | ||||
|   ) | ||||
|   const pillsEnabled = !additionalFilter | ||||
|   const pillsEnabled = !additionalFilter && !query | ||||
| 
 | ||||
|   const [pillFilter, setPillFilter] = useState<string | undefined>(undefined) | ||||
| 
 | ||||
|   const selectFilter = (pill: string | undefined) => () => { | ||||
|   const selectPill = (pill: string | undefined) => () => { | ||||
|     setPillFilter(pill) | ||||
|     setPage(0) | ||||
|     track('select search category', { category: pill ?? 'all' }) | ||||
|   } | ||||
| 
 | ||||
|   const { filters, numericFilters } = useMemo(() => { | ||||
|     let filters = [ | ||||
|       filter === 'open' ? 'isResolved:false' : '', | ||||
|       filter === 'closed' ? 'isResolved:false' : '', | ||||
|       filter === 'resolved' ? 'isResolved:true' : '', | ||||
|       additionalFilter?.creatorId | ||||
|         ? `creatorId:${additionalFilter.creatorId}` | ||||
|         : '', | ||||
|       additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '', | ||||
|       additionalFilter?.groupSlug | ||||
|         ? `groupLinks.slug:${additionalFilter.groupSlug}` | ||||
|         : '', | ||||
|       pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' | ||||
|         ? `groupLinks.slug:${pillFilter}` | ||||
|         : '', | ||||
|       pillFilter === 'personal' | ||||
|         ? // Show contracts in groups that the user is a member of
 | ||||
|           memberGroupSlugs | ||||
|             .map((slug) => `groupLinks.slug:${slug}`) | ||||
|             // Show contracts created by users the user follows
 | ||||
|             .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) | ||||
|             // Show contracts bet on by users the user follows
 | ||||
|             .concat( | ||||
|               follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] | ||||
|             ) | ||||
|         : '', | ||||
|       // Subtract contracts you bet on from For you.
 | ||||
|       pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', | ||||
|       pillFilter === 'your-bets' && user | ||||
|         ? // Show contracts bet on by the user
 | ||||
|           `uniqueBettorIds:${user.id}` | ||||
|         : '', | ||||
|     ].filter((f) => f) | ||||
|     // Hack to make Algolia work.
 | ||||
|     filters = ['', ...filters] | ||||
|   const additionalFilters = [ | ||||
|     additionalFilter?.creatorId | ||||
|       ? `creatorId:${additionalFilter.creatorId}` | ||||
|       : '', | ||||
|     additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '', | ||||
|     additionalFilter?.groupSlug | ||||
|       ? `groupLinks.slug:${additionalFilter.groupSlug}` | ||||
|       : '', | ||||
|   ] | ||||
|   const facetFilters = query | ||||
|     ? additionalFilters | ||||
|     : [ | ||||
|         ...additionalFilters, | ||||
|         filter === 'open' ? 'isResolved:false' : '', | ||||
|         filter === 'closed' ? 'isResolved:false' : '', | ||||
|         filter === 'resolved' ? 'isResolved:true' : '', | ||||
|         pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' | ||||
|           ? `groupLinks.slug:${pillFilter}` | ||||
|           : '', | ||||
|         pillFilter === 'personal' | ||||
|           ? // Show contracts in groups that the user is a member of
 | ||||
|             memberGroupSlugs | ||||
|               .map((slug) => `groupLinks.slug:${slug}`) | ||||
|               // Show contracts created by users the user follows
 | ||||
|               .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) | ||||
|               // Show contracts bet on by users the user follows
 | ||||
|               .concat( | ||||
|                 follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] | ||||
|               ) | ||||
|           : '', | ||||
|         // Subtract contracts you bet on from For you.
 | ||||
|         pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', | ||||
|         pillFilter === 'your-bets' && user | ||||
|           ? // Show contracts bet on by the user
 | ||||
|             `uniqueBettorIds:${user.id}` | ||||
|           : '', | ||||
|       ].filter((f) => f) | ||||
| 
 | ||||
|     const numericFilters = [ | ||||
|       filter === 'open' ? `closeTime > ${Date.now()}` : '', | ||||
|       filter === 'closed' ? `closeTime <= ${Date.now()}` : '', | ||||
|     ].filter((f) => f) | ||||
| 
 | ||||
|     return { filters, numericFilters } | ||||
|   }, [ | ||||
|     filter, | ||||
|     Object.values(additionalFilter ?? {}).join(','), | ||||
|     memberGroupSlugs.join(','), | ||||
|     (follows ?? []).join(','), | ||||
|     pillFilter, | ||||
|   ]) | ||||
|   const numericFilters = query | ||||
|     ? [] | ||||
|     : [ | ||||
|         filter === 'open' ? `closeTime > ${Date.now()}` : '', | ||||
|         filter === 'closed' ? `closeTime <= ${Date.now()}` : '', | ||||
|       ].filter((f) => f) | ||||
| 
 | ||||
|   const indexName = `${indexPrefix}contracts-${sort}` | ||||
|   const index = useMemo(() => searchClient.initIndex(indexName), [indexName]) | ||||
|   const searchIndex = useMemo( | ||||
|     () => searchClient.initIndex(searchIndexName), | ||||
|     [searchIndexName] | ||||
|   ) | ||||
| 
 | ||||
|   const [page, setPage] = useState(0) | ||||
|   const [numPages, setNumPages] = useState(1) | ||||
|   const [hitsByPage, setHitsByPage] = useState<{ [page: string]: Contract[] }>( | ||||
|     {} | ||||
|   ) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     let wasMostRecentQuery = true | ||||
|     const algoliaIndex = query ? searchIndex : index | ||||
| 
 | ||||
|     algoliaIndex | ||||
|       .search(query, { | ||||
|         facetFilters, | ||||
|         numericFilters, | ||||
|         page, | ||||
|         hitsPerPage: 20, | ||||
|       }) | ||||
|       .then((results) => { | ||||
|         if (!wasMostRecentQuery) return | ||||
| 
 | ||||
|         if (page === 0) { | ||||
|           setHitsByPage({ | ||||
|             [0]: results.hits as any as Contract[], | ||||
|           }) | ||||
|         } else { | ||||
|           setHitsByPage((hitsByPage) => ({ | ||||
|             ...hitsByPage, | ||||
|             [page]: results.hits, | ||||
|           })) | ||||
|         } | ||||
|         setNumPages(results.nbPages) | ||||
|       }) | ||||
|     return () => { | ||||
|       wasMostRecentQuery = false | ||||
|     } | ||||
|     // Note numeric filters are unique based on current time, so can't compare
 | ||||
|     // them by value.
 | ||||
|   }, [query, page, index, searchIndex, JSON.stringify(facetFilters), filter]) | ||||
| 
 | ||||
|   const loadMore = () => { | ||||
|     if (page >= numPages - 1) return | ||||
| 
 | ||||
|     const haveLoadedCurrentPage = hitsByPage[page] | ||||
|     if (haveLoadedCurrentPage) setPage(page + 1) | ||||
|   } | ||||
| 
 | ||||
|   const hits = range(0, page + 1) | ||||
|     .map((p) => hitsByPage[p] ?? []) | ||||
|     .flat() | ||||
| 
 | ||||
|   const contracts = hits.filter( | ||||
|     (c) => !additionalFilter?.excludeContractIds?.includes(c.id) | ||||
|   ) | ||||
| 
 | ||||
|   const showTime = | ||||
|     sort === 'close-date' || sort === 'resolve-date' ? sort : undefined | ||||
| 
 | ||||
|   const updateQuery = (newQuery: string) => { | ||||
|     setQuery(newQuery) | ||||
|     setPage(0) | ||||
|   } | ||||
| 
 | ||||
|   const selectFilter = (newFilter: filter) => { | ||||
|     if (newFilter === filter) return | ||||
|     setFilter(newFilter) | ||||
|     setPage(0) | ||||
|     trackCallback('select search filter', { filter: newFilter }) | ||||
|   } | ||||
| 
 | ||||
|   const selectSort = (newSort: Sort) => { | ||||
|     if (newSort === sort) return | ||||
| 
 | ||||
|     setPage(0) | ||||
|     setSort(newSort) | ||||
|     track('select sort', { sort: newSort }) | ||||
|   } | ||||
| 
 | ||||
|   if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { | ||||
|     return ( | ||||
|  | @ -190,44 +256,40 @@ export function ContractSearch(props: { | |||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <InstantSearch searchClient={searchClient} indexName={indexName}> | ||||
|     <Col> | ||||
|       <Row className="gap-1 sm:gap-2"> | ||||
|         <SearchBox | ||||
|           className="flex-1" | ||||
|           placeholder={showPlaceHolder ? `Search ${filter} contracts` : ''} | ||||
|           classNames={{ | ||||
|             form: 'before:top-6', | ||||
|             input: '!pl-10 !input !input-bordered shadow-none w-[100px]', | ||||
|             resetIcon: 'mt-2 hidden sm:flex', | ||||
|           }} | ||||
|         <input | ||||
|           type="text" | ||||
|           value={query} | ||||
|           onChange={(e) => updateQuery(e.target.value)} | ||||
|           placeholder={showPlaceHolder ? `Search ${filter} markets` : ''} | ||||
|           className="input input-bordered w-full" | ||||
|         /> | ||||
|         {/*// TODO track WHICH filter users are using*/} | ||||
|         <select | ||||
|           className="!select !select-bordered" | ||||
|           value={filter} | ||||
|           onChange={(e) => setFilter(e.target.value as filter)} | ||||
|           onBlur={trackCallback('select search filter', { filter })} | ||||
|         > | ||||
|           <option value="open">Open</option> | ||||
|           <option value="closed">Closed</option> | ||||
|           <option value="resolved">Resolved</option> | ||||
|           <option value="all">All</option> | ||||
|         </select> | ||||
|         {!hideOrderSelector && ( | ||||
|           <SortBy | ||||
|             items={sortIndexes} | ||||
|             classNames={{ | ||||
|               select: '!select !select-bordered', | ||||
|             }} | ||||
|             onBlur={trackCallback('select search sort', { sort })} | ||||
|           /> | ||||
|         {!query && ( | ||||
|           <select | ||||
|             className="select select-bordered" | ||||
|             value={filter} | ||||
|             onChange={(e) => selectFilter(e.target.value as filter)} | ||||
|           > | ||||
|             <option value="open">Open</option> | ||||
|             <option value="closed">Closed</option> | ||||
|             <option value="resolved">Resolved</option> | ||||
|             <option value="all">All</option> | ||||
|           </select> | ||||
|         )} | ||||
|         {!hideOrderSelector && !query && ( | ||||
|           <select | ||||
|             className="select select-bordered" | ||||
|             value={sort} | ||||
|             onChange={(e) => selectSort(e.target.value as Sort)} | ||||
|           > | ||||
|             {sortOptions.map((option) => ( | ||||
|               <option key={option.value} value={option.value}> | ||||
|                 {option.label} | ||||
|               </option> | ||||
|             ))} | ||||
|           </select> | ||||
|         )} | ||||
|         <Configure | ||||
|           facetFilters={filters} | ||||
|           numericFilters={numericFilters} | ||||
|           // Page resets on filters change.
 | ||||
|           page={0} | ||||
|         /> | ||||
|       </Row> | ||||
| 
 | ||||
|       <Spacer h={3} /> | ||||
|  | @ -237,14 +299,14 @@ export function ContractSearch(props: { | |||
|           <PillButton | ||||
|             key={'all'} | ||||
|             selected={pillFilter === undefined} | ||||
|             onSelect={selectFilter(undefined)} | ||||
|             onSelect={selectPill(undefined)} | ||||
|           > | ||||
|             All | ||||
|           </PillButton> | ||||
|           <PillButton | ||||
|             key={'personal'} | ||||
|             selected={pillFilter === 'personal'} | ||||
|             onSelect={selectFilter('personal')} | ||||
|             onSelect={selectPill('personal')} | ||||
|           > | ||||
|             {user ? 'For you' : 'Featured'} | ||||
|           </PillButton> | ||||
|  | @ -253,7 +315,7 @@ export function ContractSearch(props: { | |||
|             <PillButton | ||||
|               key={'your-bets'} | ||||
|               selected={pillFilter === 'your-bets'} | ||||
|               onSelect={selectFilter('your-bets')} | ||||
|               onSelect={selectPill('your-bets')} | ||||
|             > | ||||
|               Your bets | ||||
|             </PillButton> | ||||
|  | @ -264,7 +326,7 @@ export function ContractSearch(props: { | |||
|               <PillButton | ||||
|                 key={slug} | ||||
|                 selected={pillFilter === slug} | ||||
|                 onSelect={selectFilter(slug)} | ||||
|                 onSelect={selectPill(slug)} | ||||
|               > | ||||
|                 {name} | ||||
|               </PillButton> | ||||
|  | @ -280,103 +342,17 @@ export function ContractSearch(props: { | |||
|       memberGroupSlugs.length === 0 ? ( | ||||
|         <>You're not following anyone, nor in any of your own groups yet.</> | ||||
|       ) : ( | ||||
|         <ContractSearchInner | ||||
|           querySortOptions={querySortOptions} | ||||
|         <ContractsGrid | ||||
|           contracts={contracts} | ||||
|           loadMore={loadMore} | ||||
|           hasMore={true} | ||||
|           showTime={showTime} | ||||
|           onContractClick={onContractClick} | ||||
|           overrideGridClassName={overrideGridClassName} | ||||
|           excludeContractIds={additionalFilter?.excludeContractIds} | ||||
|           highlightOptions={highlightOptions} | ||||
|           cardHideOptions={cardHideOptions} | ||||
|         /> | ||||
|       )} | ||||
|     </InstantSearch> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function ContractSearchInner(props: { | ||||
|   querySortOptions?: { | ||||
|     defaultSort: Sort | ||||
|     shouldLoadFromStorage?: boolean | ||||
|   } | ||||
|   onContractClick?: (contract: Contract) => void | ||||
|   overrideGridClassName?: string | ||||
|   hideQuickBet?: boolean | ||||
|   excludeContractIds?: string[] | ||||
|   highlightOptions?: ContractHighlightOptions | ||||
|   cardHideOptions?: { | ||||
|     hideQuickBet?: boolean | ||||
|     hideGroupLink?: boolean | ||||
|   } | ||||
| }) { | ||||
|   const { | ||||
|     querySortOptions, | ||||
|     onContractClick, | ||||
|     overrideGridClassName, | ||||
|     cardHideOptions, | ||||
|     excludeContractIds, | ||||
|     highlightOptions, | ||||
|   } = props | ||||
|   const { initialQuery } = useInitialQueryAndSort(querySortOptions) | ||||
| 
 | ||||
|   const { query, setQuery, setSort } = useUpdateQueryAndSort({ | ||||
|     shouldLoadFromStorage: true, | ||||
|   }) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     setQuery(initialQuery) | ||||
|   }, [initialQuery]) | ||||
| 
 | ||||
|   const { currentRefinement: index } = useSortBy({ | ||||
|     items: [], | ||||
|   }) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     setQuery(query) | ||||
|   }, [query]) | ||||
| 
 | ||||
|   const isFirstRender = useRef(true) | ||||
|   useEffect(() => { | ||||
|     if (isFirstRender.current) { | ||||
|       isFirstRender.current = false | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     const sort = index.split('contracts-')[1] as Sort | ||||
|     if (sort) { | ||||
|       setSort(sort) | ||||
|     } | ||||
|   }, [index]) | ||||
| 
 | ||||
|   const [isInitialLoad, setIsInitialLoad] = useState(true) | ||||
|   useEffect(() => { | ||||
|     const id = setTimeout(() => setIsInitialLoad(false), 1000) | ||||
|     return () => clearTimeout(id) | ||||
|   }, []) | ||||
| 
 | ||||
|   const { showMore, hits, isLastPage } = useInfiniteHits() | ||||
|   let contracts = hits as any as Contract[] | ||||
| 
 | ||||
|   if (isInitialLoad && contracts.length === 0) return <></> | ||||
| 
 | ||||
|   const showTime = index.endsWith('close-date') | ||||
|     ? 'close-date' | ||||
|     : index.endsWith('resolve-date') | ||||
|     ? 'resolve-date' | ||||
|     : undefined | ||||
| 
 | ||||
|   if (excludeContractIds) | ||||
|     contracts = contracts.filter((c) => !excludeContractIds.includes(c.id)) | ||||
| 
 | ||||
|   return ( | ||||
|     <ContractsGrid | ||||
|       contracts={contracts} | ||||
|       loadMore={showMore} | ||||
|       hasMore={!isLastPage} | ||||
|       showTime={showTime} | ||||
|       onContractClick={onContractClick} | ||||
|       overrideGridClassName={overrideGridClassName} | ||||
|       highlightOptions={highlightOptions} | ||||
|       cardHideOptions={cardHideOptions} | ||||
|     /> | ||||
|     </Col> | ||||
|   ) | ||||
| } | ||||
|  |  | |||
|  | @ -30,7 +30,7 @@ import { useContractWithPreload } from 'web/hooks/use-contract' | |||
| import { useUser } from 'web/hooks/use-user' | ||||
| import { track } from '@amplitude/analytics-browser' | ||||
| import { trackCallback } from 'web/lib/service/analytics' | ||||
| import { formatNumericProbability } from 'common/pseudo-numeric' | ||||
| import { getMappedValue } from 'common/pseudo-numeric' | ||||
| 
 | ||||
| export function ContractCard(props: { | ||||
|   contract: Contract | ||||
|  | @ -115,7 +115,8 @@ export function ContractCard(props: { | |||
|               {question} | ||||
|             </p> | ||||
| 
 | ||||
|             {outcomeType === 'FREE_RESPONSE' && | ||||
|             {(outcomeType === 'FREE_RESPONSE' || | ||||
|               outcomeType === 'MULTIPLE_CHOICE') && | ||||
|               (resolution ? ( | ||||
|                 <FreeResponseOutcomeLabel | ||||
|                   contract={contract} | ||||
|  | @ -158,7 +159,8 @@ export function ContractCard(props: { | |||
|                 /> | ||||
|               )} | ||||
| 
 | ||||
|               {outcomeType === 'FREE_RESPONSE' && ( | ||||
|               {(outcomeType === 'FREE_RESPONSE' || | ||||
|                 outcomeType === 'MULTIPLE_CHOICE') && ( | ||||
|                 <FreeResponseResolutionOrChance | ||||
|                   className="self-end text-gray-600" | ||||
|                   contract={contract} | ||||
|  | @ -210,7 +212,7 @@ export function BinaryResolutionOrChance(props: { | |||
| } | ||||
| 
 | ||||
| function FreeResponseTopAnswer(props: { | ||||
|   contract: FreeResponseContract | ||||
|   contract: FreeResponseContract | MultipleChoiceContract | ||||
|   truncate: 'short' | 'long' | 'none' | ||||
|   className?: string | ||||
| }) { | ||||
|  | @ -315,6 +317,12 @@ export function PseudoNumericResolutionOrExpectation(props: { | |||
|   const { resolution, resolutionValue, resolutionProbability } = contract | ||||
|   const textColor = `text-blue-400` | ||||
| 
 | ||||
|   const value = resolution | ||||
|     ? resolutionValue | ||||
|       ? resolutionValue | ||||
|       : getMappedValue(contract)(resolutionProbability ?? 0) | ||||
|     : getMappedValue(contract)(getProbability(contract)) | ||||
| 
 | ||||
|   return ( | ||||
|     <Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}> | ||||
|       {resolution ? ( | ||||
|  | @ -324,20 +332,21 @@ export function PseudoNumericResolutionOrExpectation(props: { | |||
|           {resolution === 'CANCEL' ? ( | ||||
|             <CancelLabel /> | ||||
|           ) : ( | ||||
|             <div className="text-blue-400"> | ||||
|               {resolutionValue | ||||
|                 ? formatLargeNumber(resolutionValue) | ||||
|                 : formatNumericProbability( | ||||
|                     resolutionProbability ?? 0, | ||||
|                     contract | ||||
|                   )} | ||||
|             <div | ||||
|               className={clsx('tooltip', textColor)} | ||||
|               data-tip={value.toFixed(2)} | ||||
|             > | ||||
|               {formatLargeNumber(value)} | ||||
|             </div> | ||||
|           )} | ||||
|         </> | ||||
|       ) : ( | ||||
|         <> | ||||
|           <div className={clsx('text-3xl', textColor)}> | ||||
|             {formatNumericProbability(getProbability(contract), contract)} | ||||
|           <div | ||||
|             className={clsx('tooltip text-3xl', textColor)} | ||||
|             data-tip={value.toFixed(2)} | ||||
|           > | ||||
|             {formatLargeNumber(value)} | ||||
|           </div> | ||||
|           <div className={clsx('text-base', textColor)}>expected</div> | ||||
|         </> | ||||
|  |  | |||
|  | @ -23,6 +23,9 @@ export function ContractTabs(props: { | |||
|   const { outcomeType } = contract | ||||
| 
 | ||||
|   const userBets = user && bets.filter((bet) => bet.userId === user.id) | ||||
|   const visibleBets = bets.filter( | ||||
|     (bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0 | ||||
|   ) | ||||
| 
 | ||||
|   // Load comments here, so the badge count will be correct
 | ||||
|   const updatedComments = useComments(contract.id) | ||||
|  | @ -99,7 +102,7 @@ export function ContractTabs(props: { | |||
|           content: commentActivity, | ||||
|           badge: `${comments.length}`, | ||||
|         }, | ||||
|         { title: 'Bets', content: betActivity, badge: `${bets.length}` }, | ||||
|         { title: 'Bets', content: betActivity, badge: `${visibleBets.length}` }, | ||||
|         ...(!user || !userBets?.length | ||||
|           ? [] | ||||
|           : [{ title: 'Your bets', content: yourTrades }]), | ||||
|  |  | |||
|  | @ -49,6 +49,10 @@ function duplicateContractHref(contract: Contract) { | |||
|     params.initValue = getMappedValue(contract)(contract.initialProbability) | ||||
|   } | ||||
| 
 | ||||
|   if (contract.groupLinks && contract.groupLinks.length > 0) { | ||||
|     params.groupId = contract.groupLinks[0].groupId | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     `/create?` + | ||||
|     Object.entries(params) | ||||
|  |  | |||
|  | @ -7,6 +7,7 @@ import { Button } from 'web/components/button' | |||
| import { GroupSelector } from 'web/components/groups/group-selector' | ||||
| import { | ||||
|   addContractToGroup, | ||||
|   canModifyGroupContracts, | ||||
|   removeContractFromGroup, | ||||
| } from 'web/lib/firebase/groups' | ||||
| import { User } from 'common/user' | ||||
|  | @ -57,11 +58,11 @@ export function ContractGroupsList(props: { | |||
|           <Row className="line-clamp-1 items-center gap-2"> | ||||
|             <GroupLinkItem group={group} /> | ||||
|           </Row> | ||||
|           {user && group.memberIds.includes(user.id) && ( | ||||
|           {user && canModifyGroupContracts(group, user.id) && ( | ||||
|             <Button | ||||
|               color={'gray-white'} | ||||
|               size={'xs'} | ||||
|               onClick={() => removeContractFromGroup(group, contract)} | ||||
|               onClick={() => removeContractFromGroup(group, contract, user.id)} | ||||
|             > | ||||
|               <XIcon className="h-4 w-4 text-gray-500" /> | ||||
|             </Button> | ||||
|  |  | |||
|  | @ -46,7 +46,7 @@ export function CreateGroupButton(props: { | |||
|     const newGroup = { | ||||
|       name: groupName, | ||||
|       memberIds: memberUsers.map((user) => user.id), | ||||
|       anyoneCanJoin: false, | ||||
|       anyoneCanJoin: true, | ||||
|     } | ||||
|     const result = await createGroup(newGroup).catch((e) => { | ||||
|       const errorDetails = e.details[0] | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import { Row } from 'web/components/layout/row' | ||||
| import { Col } from 'web/components/layout/col' | ||||
| import { User } from 'common/user' | ||||
| import { PrivateUser, User } from 'common/user' | ||||
| import React, { useEffect, memo, useState, useMemo } from 'react' | ||||
| import { Avatar } from 'web/components/avatar' | ||||
| import { Group } from 'common/group' | ||||
|  | @ -23,6 +23,9 @@ import { Tipper } from 'web/components/tipper' | |||
| import { sum } from 'lodash' | ||||
| import { formatMoney } from 'common/util/format' | ||||
| import { useWindowSize } from 'web/hooks/use-window-size' | ||||
| import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' | ||||
| import { ChatIcon, ChevronDownIcon } from '@heroicons/react/outline' | ||||
| import { setNotificationsAsSeen } from 'web/pages/notifications' | ||||
| 
 | ||||
| export function GroupChat(props: { | ||||
|   messages: Comment[] | ||||
|  | @ -44,6 +47,13 @@ export function GroupChat(props: { | |||
|   const router = useRouter() | ||||
|   const isMember = user && group.memberIds.includes(user?.id) | ||||
| 
 | ||||
|   const { width, height } = useWindowSize() | ||||
|   const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) | ||||
|   // Subtract bottom bar when it's showing (less than lg screen)
 | ||||
|   const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0 | ||||
|   const remainingHeight = | ||||
|     (height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight | ||||
| 
 | ||||
|   useMemo(() => { | ||||
|     // Group messages with createdTime within 2 minutes of each other.
 | ||||
|     const tempMessages = [] | ||||
|  | @ -70,9 +80,10 @@ export function GroupChat(props: { | |||
|   }, [scrollToMessageRef]) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (!isSubmitting) | ||||
|       scrollToBottomRef?.scrollTo({ top: scrollToBottomRef?.scrollHeight || 0 }) | ||||
|   }, [scrollToBottomRef, isSubmitting]) | ||||
|     if (scrollToBottomRef) | ||||
|       scrollToBottomRef.scrollTo({ top: scrollToBottomRef.scrollHeight || 0 }) | ||||
|     // Must also listen to groupedMessages as they update the height of the messaging window
 | ||||
|   }, [scrollToBottomRef, groupedMessages]) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const elementInUrl = router.asPath.split('#')[1] | ||||
|  | @ -81,6 +92,11 @@ export function GroupChat(props: { | |||
|     } | ||||
|   }, [messages, router.asPath]) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     // is mobile?
 | ||||
|     if (inputRef && width && width > 720) inputRef.focus() | ||||
|   }, [inputRef, width]) | ||||
| 
 | ||||
|   function onReplyClick(comment: Comment) { | ||||
|     setReplyToUsername(comment.userUsername) | ||||
|   } | ||||
|  | @ -98,18 +114,6 @@ export function GroupChat(props: { | |||
|     setReplyToUsername('') | ||||
|     inputRef?.focus() | ||||
|   } | ||||
|   function focusInput() { | ||||
|     inputRef?.focus() | ||||
|   } | ||||
| 
 | ||||
|   const { width, height } = useWindowSize() | ||||
|   const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) | ||||
|   // Subtract bottom bar when it's showing (less than lg screen)
 | ||||
|   const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0 | ||||
|   const remainingHeight = | ||||
|     (height ?? window.innerHeight) - | ||||
|     (containerRef?.offsetTop ?? 0) - | ||||
|     bottomBarHeight | ||||
| 
 | ||||
|   return ( | ||||
|     <Col ref={setContainerRef} style={{ height: remainingHeight }}> | ||||
|  | @ -140,7 +144,7 @@ export function GroupChat(props: { | |||
|             No messages yet. Why not{isMember ? ` ` : ' join and '} | ||||
|             <button | ||||
|               className={'cursor-pointer font-bold text-gray-700'} | ||||
|               onClick={() => focusInput()} | ||||
|               onClick={() => inputRef?.focus()} | ||||
|             > | ||||
|               add one? | ||||
|             </button> | ||||
|  | @ -175,6 +179,117 @@ export function GroupChat(props: { | |||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function GroupChatInBubble(props: { | ||||
|   messages: Comment[] | ||||
|   user: User | null | undefined | ||||
|   privateUser: PrivateUser | null | undefined | ||||
|   group: Group | ||||
|   tips: CommentTipMap | ||||
| }) { | ||||
|   const { messages, user, group, tips, privateUser } = props | ||||
|   const [shouldShowChat, setShouldShowChat] = useState(false) | ||||
|   const router = useRouter() | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const groupsWithChatEmphasis = [ | ||||
|       'welcome', | ||||
|       'bugs', | ||||
|       'manifold-features-25bad7c7792e', | ||||
|       'updates', | ||||
|     ] | ||||
|     if ( | ||||
|       router.asPath.includes('/chat') || | ||||
|       groupsWithChatEmphasis.includes( | ||||
|         router.asPath.split('/group/')[1].split('/')[0] | ||||
|       ) | ||||
|     ) { | ||||
|       setShouldShowChat(true) | ||||
|     } | ||||
|     // Leave chat open between groups if user is using chat?
 | ||||
|     else { | ||||
|       setShouldShowChat(false) | ||||
|     } | ||||
|   }, [router.asPath]) | ||||
| 
 | ||||
|   return ( | ||||
|     <Col | ||||
|       className={clsx( | ||||
|         'fixed right-0 bottom-[0px] h-1 w-full sm:bottom-[20px] sm:right-20 sm:w-2/3 md:w-1/2 lg:right-24 lg:w-1/3 xl:right-32 xl:w-1/4', | ||||
|         shouldShowChat ? 'p-2m z-10 h-screen bg-white' : '' | ||||
|       )} | ||||
|     > | ||||
|       {shouldShowChat && ( | ||||
|         <GroupChat messages={messages} user={user} group={group} tips={tips} /> | ||||
|       )} | ||||
|       <button | ||||
|         type="button" | ||||
|         className={clsx( | ||||
|           'fixed right-1 inline-flex items-center rounded-full border md:right-2 lg:right-5 xl:right-10' + | ||||
|             ' border-transparent p-3 text-white shadow-sm lg:p-4' + | ||||
|             ' focus:outline-none focus:ring-2  focus:ring-offset-2 ' + | ||||
|             ' bottom-[70px] ', | ||||
|           shouldShowChat | ||||
|             ? 'bottom-auto top-2 bg-gray-600 hover:bg-gray-400 focus:ring-gray-500 sm:bottom-[70px] sm:top-auto ' | ||||
|             : ' bg-indigo-600  hover:bg-indigo-700 focus:ring-indigo-500' | ||||
|         )} | ||||
|         onClick={() => { | ||||
|           // router.push('/chat')
 | ||||
|           setShouldShowChat(!shouldShowChat) | ||||
|           track('mobile group chat button') | ||||
|         }} | ||||
|       > | ||||
|         {!shouldShowChat ? ( | ||||
|           <ChatIcon className="h-10 w-10" aria-hidden="true" /> | ||||
|         ) : ( | ||||
|           <ChevronDownIcon className={'h-10 w-10'} aria-hidden={'true'} /> | ||||
|         )} | ||||
|         {privateUser && ( | ||||
|           <GroupChatNotificationsIcon | ||||
|             group={group} | ||||
|             privateUser={privateUser} | ||||
|             shouldSetAsSeen={shouldShowChat} | ||||
|           /> | ||||
|         )} | ||||
|       </button> | ||||
|     </Col> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function GroupChatNotificationsIcon(props: { | ||||
|   group: Group | ||||
|   privateUser: PrivateUser | ||||
|   shouldSetAsSeen: boolean | ||||
| }) { | ||||
|   const { privateUser, group, shouldSetAsSeen } = props | ||||
|   const preferredNotificationsForThisGroup = useUnseenPreferredNotifications( | ||||
|     privateUser, | ||||
|     { | ||||
|       customHref: `/group/${group.slug}`, | ||||
|     } | ||||
|   ) | ||||
|   useEffect(() => { | ||||
|     preferredNotificationsForThisGroup.forEach((notification) => { | ||||
|       if ( | ||||
|         (shouldSetAsSeen && notification.isSeenOnHref?.includes('chat')) || | ||||
|         // old style chat notif that simply ended with the group slug
 | ||||
|         notification.isSeenOnHref?.endsWith(group.slug) | ||||
|       ) { | ||||
|         setNotificationsAsSeen([notification]) | ||||
|       } | ||||
|     }) | ||||
|   }, [group.slug, preferredNotificationsForThisGroup, shouldSetAsSeen]) | ||||
| 
 | ||||
|   return ( | ||||
|     <div | ||||
|       className={ | ||||
|         preferredNotificationsForThisGroup.length > 0 && !shouldSetAsSeen | ||||
|           ? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500' | ||||
|           : 'hidden' | ||||
|       } | ||||
|     ></div> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| const GroupMessage = memo(function GroupMessage_(props: { | ||||
|   user: User | null | undefined | ||||
|   comment: Comment | ||||
|  |  | |||
|  | @ -9,7 +9,7 @@ import { | |||
| import clsx from 'clsx' | ||||
| import { CreateGroupButton } from 'web/components/groups/create-group-button' | ||||
| import { useState } from 'react' | ||||
| import { useMemberGroups } from 'web/hooks/use-group' | ||||
| import { useMemberGroups, useOpenGroups } from 'web/hooks/use-group' | ||||
| import { User } from 'common/user' | ||||
| import { searchInAny } from 'common/util/parse' | ||||
| 
 | ||||
|  | @ -27,10 +27,15 @@ export function GroupSelector(props: { | |||
|   const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false) | ||||
|   const { showSelector, showLabel, ignoreGroupIds } = options | ||||
|   const [query, setQuery] = useState('') | ||||
|   const memberGroups = (useMemberGroups(creator?.id) ?? []).filter( | ||||
|     (group) => !ignoreGroupIds?.includes(group.id) | ||||
|   ) | ||||
|   const filteredGroups = memberGroups.filter((group) => | ||||
|   const openGroups = useOpenGroups() | ||||
|   const availableGroups = openGroups | ||||
|     .concat( | ||||
|       (useMemberGroups(creator?.id) ?? []).filter( | ||||
|         (g) => !openGroups.map((og) => og.id).includes(g.id) | ||||
|       ) | ||||
|     ) | ||||
|     .filter((group) => !ignoreGroupIds?.includes(group.id)) | ||||
|   const filteredGroups = availableGroups.filter((group) => | ||||
|     searchInAny(query, group.name) | ||||
|   ) | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,15 +1,18 @@ | |||
| import { useState } from 'react' | ||||
| import clsx from 'clsx' | ||||
| import { QrcodeIcon } from '@heroicons/react/outline' | ||||
| import { DotsHorizontalIcon } from '@heroicons/react/solid' | ||||
| 
 | ||||
| import { formatMoney } from 'common/util/format' | ||||
| import { fromNow } from 'web/lib/util/time' | ||||
| import { Col } from 'web/components/layout/col' | ||||
| import { Row } from 'web/components/layout/row' | ||||
| import { Claim, Manalink } from 'common/manalink' | ||||
| import { useState } from 'react' | ||||
| import { ShareIconButton } from './share-icon-button' | ||||
| import { DotsHorizontalIcon } from '@heroicons/react/solid' | ||||
| import { contractDetailsButtonClassName } from './contract/contract-info-dialog' | ||||
| import { useUserById } from 'web/hooks/use-user' | ||||
| import getManalinkUrl from 'web/get-manalink-url' | ||||
| 
 | ||||
| export type ManalinkInfo = { | ||||
|   expiresTime: number | null | ||||
|   maxUses: number | null | ||||
|  | @ -78,7 +81,9 @@ export function ManalinkCardFromView(props: { | |||
|   const { className, link, highlightedSlug } = props | ||||
|   const { message, amount, expiresTime, maxUses, claims } = link | ||||
|   const [showDetails, setShowDetails] = useState(false) | ||||
| 
 | ||||
|   const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${200}x${200}&data=${getManalinkUrl( | ||||
|     link.slug | ||||
|   )}` | ||||
|   return ( | ||||
|     <Col> | ||||
|       <Col | ||||
|  | @ -127,6 +132,14 @@ export function ManalinkCardFromView(props: { | |||
|           > | ||||
|             {formatMoney(amount)} | ||||
|           </div> | ||||
| 
 | ||||
|           <button | ||||
|             onClick={() => (window.location.href = qrUrl)} | ||||
|             className={clsx(contractDetailsButtonClassName)} | ||||
|           > | ||||
|             <QrcodeIcon className="h-6 w-6" /> | ||||
|           </button> | ||||
| 
 | ||||
|           <ShareIconButton | ||||
|             toastClassName={'-left-48 min-w-[250%]'} | ||||
|             buttonClassName={'transition-colors'} | ||||
|  |  | |||
|  | @ -12,6 +12,7 @@ import dayjs from 'dayjs' | |||
| import { Button } from '../button' | ||||
| import { getManalinkUrl } from 'web/pages/links' | ||||
| import { DuplicateIcon } from '@heroicons/react/outline' | ||||
| import { QRCode } from '../qr-code' | ||||
| 
 | ||||
| export function CreateLinksButton(props: { | ||||
|   user: User | ||||
|  | @ -98,6 +99,8 @@ function CreateManalinkForm(props: { | |||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   const url = getManalinkUrl(highlightedSlug) | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       {!finishedCreating && ( | ||||
|  | @ -199,17 +202,17 @@ function CreateManalinkForm(props: { | |||
|               copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : '' | ||||
|             )} | ||||
|           > | ||||
|             <div className="w-full select-text truncate"> | ||||
|               {getManalinkUrl(highlightedSlug)} | ||||
|             </div> | ||||
|             <div className="w-full select-text truncate">{url}</div> | ||||
|             <DuplicateIcon | ||||
|               onClick={() => { | ||||
|                 navigator.clipboard.writeText(getManalinkUrl(highlightedSlug)) | ||||
|                 navigator.clipboard.writeText(url) | ||||
|                 setCopyPressed(true) | ||||
|               }} | ||||
|               className="my-auto ml-2 h-5 w-5 cursor-pointer transition hover:opacity-50" | ||||
|             /> | ||||
|           </Row> | ||||
| 
 | ||||
|           <QRCode url={url} className="self-center" /> | ||||
|         </> | ||||
|       )} | ||||
|     </> | ||||
|  |  | |||
|  | @ -18,7 +18,7 @@ import { ManifoldLogo } from './manifold-logo' | |||
| import { MenuButton } from './menu' | ||||
| import { ProfileSummary } from './profile-menu' | ||||
| import NotificationsIcon from 'web/components/notifications-icon' | ||||
| import React, { useEffect, useState } from 'react' | ||||
| import React, { useMemo, useState } from 'react' | ||||
| import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' | ||||
| import { CreateQuestionButton } from 'web/components/create-question-button' | ||||
| import { useMemberGroups } from 'web/hooks/use-group' | ||||
|  | @ -27,7 +27,6 @@ import { trackCallback, withTracking } from 'web/lib/service/analytics' | |||
| import { Group, GROUP_CHAT_SLUG } from 'common/group' | ||||
| import { Spacer } from '../layout/spacer' | ||||
| import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' | ||||
| import { setNotificationsAsSeen } from 'web/pages/notifications' | ||||
| import { PrivateUser } from 'common/user' | ||||
| import { useWindowSize } from 'web/hooks/use-window-size' | ||||
| 
 | ||||
|  | @ -216,7 +215,7 @@ export default function Sidebar(props: { className?: string }) { | |||
|     ) ?? [] | ||||
|   ).map((group: Group) => ({ | ||||
|     name: group.name, | ||||
|     href: `${groupPath(group.slug)}/${GROUP_CHAT_SLUG}`, | ||||
|     href: `${groupPath(group.slug)}`, | ||||
|   })) | ||||
| 
 | ||||
|   return ( | ||||
|  | @ -294,30 +293,22 @@ function GroupsList(props: { | |||
|     memberItems.length > 0 ? memberItems.length : undefined | ||||
|   ) | ||||
| 
 | ||||
|   // Set notification as seen if our current page is equal to the isSeenOnHref property
 | ||||
|   useEffect(() => { | ||||
|     const currentPageWithoutQuery = currentPage.split('?')[0] | ||||
|     const currentPageGroupSlug = currentPageWithoutQuery.split('/')[2] | ||||
|     preferredNotifications.forEach((notification) => { | ||||
|       if ( | ||||
|         notification.isSeenOnHref === currentPage || | ||||
|         // Old chat style group chat notif was just /group/slug
 | ||||
|         (notification.isSeenOnHref && | ||||
|           currentPageWithoutQuery.includes(notification.isSeenOnHref)) || | ||||
|         // They're on the home page, so if they've a chat notif, they're seeing the chat
 | ||||
|         (notification.isSeenOnHref?.endsWith(GROUP_CHAT_SLUG) && | ||||
|           currentPageWithoutQuery.endsWith(currentPageGroupSlug)) | ||||
|       ) { | ||||
|         setNotificationsAsSeen([notification]) | ||||
|       } | ||||
|     }) | ||||
|   }, [currentPage, preferredNotifications]) | ||||
| 
 | ||||
|   const { height } = useWindowSize() | ||||
|   const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) | ||||
|   const remainingHeight = | ||||
|     (height ?? window.innerHeight) - (containerRef?.offsetTop ?? 0) | ||||
| 
 | ||||
|   const notifIsForThisItem = useMemo( | ||||
|     () => (itemHref: string) => | ||||
|       preferredNotifications.some( | ||||
|         (n) => | ||||
|           !n.isSeen && | ||||
|           (n.isSeenOnHref === itemHref || | ||||
|             n.isSeenOnHref?.replace('/chat', '') === itemHref) | ||||
|       ), | ||||
|     [preferredNotifications] | ||||
|   ) | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <SidebarItem | ||||
|  | @ -332,19 +323,19 @@ function GroupsList(props: { | |||
|       > | ||||
|         {memberItems.map((item) => ( | ||||
|           <a | ||||
|             key={item.href} | ||||
|             href={item.href} | ||||
|             href={ | ||||
|               item.href + | ||||
|               (notifIsForThisItem(item.href) ? '/' + GROUP_CHAT_SLUG : '') | ||||
|             } | ||||
|             key={item.name} | ||||
|             onClick={trackCallback('sidebar: ' + item.name)} | ||||
|             className={clsx( | ||||
|               'cursor-pointer truncate', | ||||
|               'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900', | ||||
|               preferredNotifications.some( | ||||
|                 (n) => | ||||
|                   !n.isSeen && | ||||
|                   (n.isSeenOnHref === item.href || | ||||
|                     n.isSeenOnHref === item.href.replace('/chat', '')) | ||||
|               ) && 'font-bold' | ||||
|               notifIsForThisItem(item.href) && 'font-bold' | ||||
|             )} | ||||
|           > | ||||
|             <span className="truncate">{item.name}</span> | ||||
|             {item.name} | ||||
|           </a> | ||||
|         ))} | ||||
|       </div> | ||||
|  |  | |||
							
								
								
									
										173
									
								
								web/components/onboarding/welcome.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								web/components/onboarding/welcome.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,173 @@ | |||
| import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid' | ||||
| import clsx from 'clsx' | ||||
| import { useState } from 'react' | ||||
| import { useUser } from 'web/hooks/use-user' | ||||
| import { updateUser } from 'web/lib/firebase/users' | ||||
| import { Col } from '../layout/col' | ||||
| import { Modal } from '../layout/modal' | ||||
| import { Row } from '../layout/row' | ||||
| import { Title } from '../title' | ||||
| 
 | ||||
| export default function Welcome() { | ||||
|   const user = useUser() | ||||
|   const [open, setOpen] = useState(true) | ||||
|   const [page, setPage] = useState(0) | ||||
|   const TOTAL_PAGES = 4 | ||||
| 
 | ||||
|   function increasePage() { | ||||
|     if (page < TOTAL_PAGES - 1) { | ||||
|       setPage(page + 1) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   function decreasePage() { | ||||
|     if (page > 0) { | ||||
|       setPage(page - 1) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async function setUserHasSeenWelcome() { | ||||
|     if (user) { | ||||
|       await updateUser(user.id, { ['shouldShowWelcome']: false }) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   if (!user || !user.shouldShowWelcome) { | ||||
|     return <></> | ||||
|   } else | ||||
|     return ( | ||||
|       <Modal | ||||
|         open={open} | ||||
|         setOpen={(newOpen) => { | ||||
|           setUserHasSeenWelcome() | ||||
|           setOpen(newOpen) | ||||
|         }} | ||||
|       > | ||||
|         <Col className="h-[32rem] place-content-between rounded-md bg-white px-8 py-6 text-sm font-light md:h-[40rem] md:text-lg"> | ||||
|           {page === 0 && <Page0 />} | ||||
|           {page === 1 && <Page1 />} | ||||
|           {page === 2 && <Page2 />} | ||||
|           {page === 3 && <Page3 />} | ||||
|           <Col> | ||||
|             <Row className="place-content-between"> | ||||
|               <ChevronLeftIcon | ||||
|                 className={clsx( | ||||
|                   'h-10 w-10 text-gray-400 hover:text-gray-500', | ||||
|                   page === 0 ? 'disabled invisible' : '' | ||||
|                 )} | ||||
|                 onClick={decreasePage} | ||||
|               /> | ||||
|               <PageIndicator page={page} totalpages={TOTAL_PAGES} /> | ||||
|               <ChevronRightIcon | ||||
|                 className={clsx( | ||||
|                   'h-10 w-10 text-indigo-500 hover:text-indigo-600', | ||||
|                   page === TOTAL_PAGES - 1 ? 'disabled invisible' : '' | ||||
|                 )} | ||||
|                 onClick={increasePage} | ||||
|               /> | ||||
|             </Row> | ||||
|             <u | ||||
|               className="self-center text-xs text-gray-500" | ||||
|               onClick={() => { | ||||
|                 setOpen(false) | ||||
|                 setUserHasSeenWelcome() | ||||
|               }} | ||||
|             > | ||||
|               I got the gist, exit welcome | ||||
|             </u> | ||||
|           </Col> | ||||
|         </Col> | ||||
|       </Modal> | ||||
|     ) | ||||
| } | ||||
| 
 | ||||
| function PageIndicator(props: { page: number; totalpages: number }) { | ||||
|   const { page, totalpages } = props | ||||
|   return ( | ||||
|     <Row> | ||||
|       {[...Array(totalpages)].map((e, i) => ( | ||||
|         <div | ||||
|           className={clsx( | ||||
|             'mx-1.5 my-auto h-1.5 w-1.5 rounded-full', | ||||
|             i === page ? 'bg-indigo-500' : 'bg-gray-300' | ||||
|           )} | ||||
|         /> | ||||
|       ))} | ||||
|     </Row> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function Page0() { | ||||
|   return ( | ||||
|     <> | ||||
|       <img | ||||
|         className="h-2/3 w-2/3 place-self-center object-contain" | ||||
|         src="/welcome/manipurple.png" | ||||
|       /> | ||||
|       <Title className="text-center" text="Welcome to Manifold Markets!" /> | ||||
|       <p> | ||||
|         Manifold Markets is a place where anyone can ask a question about the | ||||
|         future. | ||||
|       </p> | ||||
|       <div className="mt-4">For example,</div> | ||||
|       <div className="mt-2 font-normal text-indigo-700"> | ||||
|         “Will Michelle Obama be the next president of the United States?” | ||||
|       </div> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function Page1() { | ||||
|   return ( | ||||
|     <> | ||||
|       <p> | ||||
|         Your question becomes a prediction market that people can bet{' '} | ||||
|         <span className="font-normal text-indigo-700">mana (M$)</span> on. | ||||
|       </p> | ||||
|       <div className="mt-8 font-semibold">The core idea</div> | ||||
|       <div className="mt-2"> | ||||
|         If people have to put their mana where their mouth is, you’ll get a | ||||
|         pretty accurate answer! | ||||
|       </div> | ||||
|       <video loop autoPlay className="my-4 h-full w-full"> | ||||
|         <source src="/welcome/mana-example.mp4" type="video/mp4" /> | ||||
|         Your browser does not support video | ||||
|       </video> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function Page2() { | ||||
|   return ( | ||||
|     <> | ||||
|       <p> | ||||
|         <span className="mt-4 font-normal text-indigo-700">Mana (M$)</span> is | ||||
|         the play money you bet with. You can also turn it into a real donation | ||||
|         to charity, at a 100:1 ratio. | ||||
|       </p> | ||||
|       <div className="mt-8 font-semibold">Example</div> | ||||
|       <p className="mt-2"> | ||||
|         When you donate <span className="font-semibold">M$1000</span> to | ||||
|         Givewell, Manifold sends them{' '} | ||||
|         <span className="font-semibold">$10 USD</span>. | ||||
|       </p> | ||||
|       <video loop autoPlay className="my-4 h-full w-full"> | ||||
|         <source src="/welcome/charity.mp4" type="video/mp4" /> | ||||
|         Your browser does not support video | ||||
|       </video> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function Page3() { | ||||
|   return ( | ||||
|     <> | ||||
|       <img className="mx-auto object-contain" src="/welcome/treasure.png" /> | ||||
|       <Title className="mx-auto" text="Let's start predicting!" /> | ||||
|       <p className="mb-8"> | ||||
|         As a thank you for signing up, we’ve sent you{' '} | ||||
|         <span className="font-normal text-indigo-700">M$1000 Mana</span>{' '} | ||||
|       </p> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
|  | @ -1,4 +1,5 @@ | |||
| import clsx from 'clsx' | ||||
| import { Spacer } from './layout/spacer' | ||||
| 
 | ||||
| export function Pagination(props: { | ||||
|   page: number | ||||
|  | @ -23,6 +24,8 @@ export function Pagination(props: { | |||
| 
 | ||||
|   const maxPage = Math.ceil(totalItems / itemsPerPage) - 1 | ||||
| 
 | ||||
|   if (maxPage === 0) return <Spacer h={4} /> | ||||
| 
 | ||||
|   return ( | ||||
|     <nav | ||||
|       className={clsx( | ||||
|  |  | |||
							
								
								
									
										16
									
								
								web/components/qr-code.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								web/components/qr-code.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,16 @@ | |||
| export function QRCode(props: { | ||||
|   url: string | ||||
|   className?: string | ||||
|   width?: number | ||||
|   height?: number | ||||
| }) { | ||||
|   const { url, className, width, height } = { | ||||
|     width: 200, | ||||
|     height: 200, | ||||
|     ...props, | ||||
|   } | ||||
| 
 | ||||
|   const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${width}x${height}&data=${url}` | ||||
| 
 | ||||
|   return <img src={qrUrl} width={width} height={height} className={className} /> | ||||
| } | ||||
|  | @ -5,7 +5,7 @@ export function Title(props: { text: string; className?: string }) { | |||
|   return ( | ||||
|     <h1 | ||||
|       className={clsx( | ||||
|         'my-4 inline-block text-2xl text-indigo-700 sm:my-6 sm:text-3xl', | ||||
|         'my-4 inline-block text-2xl font-normal text-indigo-700 sm:my-6 sm:text-3xl', | ||||
|         className | ||||
|       )} | ||||
|     > | ||||
|  |  | |||
|  | @ -214,6 +214,10 @@ export function UserPage(props: { user: User; currentUser?: User }) { | |||
|           <Row className="gap-4"> | ||||
|             <FollowingButton user={user} /> | ||||
|             <FollowersButton user={user} /> | ||||
|             {currentUser && | ||||
|               ['ian', 'Austin', 'SG', 'JamesGrugett'].includes( | ||||
|                 currentUser.username | ||||
|               ) && <ReferralsButton user={user} />} | ||||
|             <GroupsButton user={user} /> | ||||
|           </Row> | ||||
| 
 | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ import { | |||
|   listenForGroup, | ||||
|   listenForGroups, | ||||
|   listenForMemberGroups, | ||||
|   listenForOpenGroups, | ||||
|   listGroups, | ||||
| } from 'web/lib/firebase/groups' | ||||
| import { getUser, getUsers } from 'web/lib/firebase/users' | ||||
|  | @ -32,6 +33,16 @@ export const useGroups = () => { | |||
|   return groups | ||||
| } | ||||
| 
 | ||||
| export const useOpenGroups = () => { | ||||
|   const [groups, setGroups] = useState<Group[]>([]) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     return listenForOpenGroups(setGroups) | ||||
|   }, []) | ||||
| 
 | ||||
|   return groups | ||||
| } | ||||
| 
 | ||||
| export const useMemberGroups = ( | ||||
|   userId: string | null | undefined, | ||||
|   options?: { withChatEnabled: boolean }, | ||||
|  |  | |||
|  | @ -18,10 +18,14 @@ export const useSaveReferral = ( | |||
|       referrer?: string | ||||
|     } | ||||
| 
 | ||||
|     const actualReferrer = referrer || options?.defaultReferrer | ||||
|     const referrerOrDefault = referrer || options?.defaultReferrer | ||||
| 
 | ||||
|     if (!user && router.isReady && actualReferrer) { | ||||
|       writeReferralInfo(actualReferrer, options?.contractId, options?.groupId) | ||||
|     if (!user && router.isReady && referrerOrDefault) { | ||||
|       writeReferralInfo(referrerOrDefault, { | ||||
|         contractId: options?.contractId, | ||||
|         overwriteReferralUsername: referrer, | ||||
|         groupId: options?.groupId, | ||||
|       }) | ||||
|     } | ||||
|   }, [user, router, options]) | ||||
| } | ||||
|  |  | |||
|  | @ -1,8 +1,6 @@ | |||
| import { defaults, debounce } from 'lodash' | ||||
| import { useRouter } from 'next/router' | ||||
| import { useEffect, useMemo, useState } from 'react' | ||||
| import { useSearchBox } from 'react-instantsearch-hooks-web' | ||||
| import { track } from 'web/lib/service/analytics' | ||||
| import { DEFAULT_SORT } from 'web/components/contract-search' | ||||
| 
 | ||||
| const MARKETS_SORT = 'markets_sort' | ||||
|  | @ -74,51 +72,71 @@ export function useInitialQueryAndSort(options?: { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| export function useUpdateQueryAndSort(props: { | ||||
|   shouldLoadFromStorage: boolean | ||||
| export function useQueryAndSortParams(options?: { | ||||
|   defaultSort?: Sort | ||||
|   shouldLoadFromStorage?: boolean | ||||
| }) { | ||||
|   const { shouldLoadFromStorage } = props | ||||
|   const { defaultSort = DEFAULT_SORT, shouldLoadFromStorage = true } = | ||||
|     options ?? {} | ||||
|   const router = useRouter() | ||||
| 
 | ||||
|   const { s: sort, q: query } = router.query as { | ||||
|     q?: string | ||||
|     s?: Sort | ||||
|   } | ||||
| 
 | ||||
|   const setSort = (sort: Sort | undefined) => { | ||||
|     if (sort !== router.query.s) { | ||||
|       router.query.s = sort | ||||
|       router.replace({ query: { ...router.query, s: sort } }, undefined, { | ||||
|         shallow: true, | ||||
|       }) | ||||
|       if (shouldLoadFromStorage) { | ||||
|         localStorage.setItem(MARKETS_SORT, sort || '') | ||||
|       } | ||||
|     router.replace({ query: { ...router.query, s: sort } }, undefined, { | ||||
|       shallow: true, | ||||
|     }) | ||||
|     if (shouldLoadFromStorage) { | ||||
|       localStorage.setItem(MARKETS_SORT, sort || '') | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const { query, refine } = useSearchBox() | ||||
|   const [queryState, setQueryState] = useState(query) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     setQueryState(query) | ||||
|   }, [query]) | ||||
| 
 | ||||
|   // Debounce router query update.
 | ||||
|   const pushQuery = useMemo( | ||||
|     () => | ||||
|       debounce((query: string | undefined) => { | ||||
|         if (query) { | ||||
|           router.query.q = query | ||||
|         } else { | ||||
|           delete router.query.q | ||||
|         } | ||||
|         router.replace({ query: router.query }, undefined, { | ||||
|         const queryObj = { ...router.query, q: query } | ||||
|         if (!query) delete queryObj.q | ||||
|         router.replace({ query: queryObj }, undefined, { | ||||
|           shallow: true, | ||||
|         }) | ||||
|         track('search', { query }) | ||||
|       }, 500), | ||||
|       }, 100), | ||||
|     [router] | ||||
|   ) | ||||
| 
 | ||||
|   const setQuery = (query: string | undefined) => { | ||||
|     refine(query ?? '') | ||||
|     setQueryState(query) | ||||
|     pushQuery(query) | ||||
|   } | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     // If there's no sort option, then set the one from localstorage
 | ||||
|     if (router.isReady && !sort && shouldLoadFromStorage) { | ||||
|       const localSort = localStorage.getItem(MARKETS_SORT) as Sort | ||||
|       if (localSort && localSort !== defaultSort) { | ||||
|         // Use replace to not break navigating back.
 | ||||
|         router.replace( | ||||
|           { query: { ...router.query, s: localSort } }, | ||||
|           undefined, | ||||
|           { shallow: true } | ||||
|         ) | ||||
|       } | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
|   return { | ||||
|     sort: sort ?? defaultSort, | ||||
|     query: queryState ?? '', | ||||
|     setSort, | ||||
|     setQuery, | ||||
|     query, | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -80,3 +80,7 @@ export function claimManalink(params: any) { | |||
| export function createGroup(params: any) { | ||||
|   return call(getFunctionUrl('creategroup'), 'POST', params) | ||||
| } | ||||
| 
 | ||||
| export function getCurrentUser(params: any) { | ||||
|   return call(getFunctionUrl('getcurrentuser'), 'GET', params) | ||||
| } | ||||
|  |  | |||
|  | @ -81,6 +81,7 @@ export async function createCommentOnGroup( | |||
| function getCommentsCollection(contractId: string) { | ||||
|   return collection(db, 'contracts', contractId, 'comments') | ||||
| } | ||||
| 
 | ||||
| function getCommentsOnGroupCollection(groupId: string) { | ||||
|   return collection(db, 'groups', groupId, 'comments') | ||||
| } | ||||
|  | @ -91,6 +92,14 @@ export async function listAllComments(contractId: string) { | |||
|   return comments | ||||
| } | ||||
| 
 | ||||
| export async function listAllCommentsOnGroup(groupId: string) { | ||||
|   const comments = await getValues<Comment>( | ||||
|     getCommentsOnGroupCollection(groupId) | ||||
|   ) | ||||
|   comments.sort((c1, c2) => c1.createdTime - c2.createdTime) | ||||
|   return comments | ||||
| } | ||||
| 
 | ||||
| export function listenForCommentsOnContract( | ||||
|   contractId: string, | ||||
|   setComments: (comments: Comment[]) => void | ||||
|  |  | |||
|  | @ -8,7 +8,6 @@ import { | |||
| } from 'firebase/firestore' | ||||
| import { sortBy, uniq } from 'lodash' | ||||
| import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group' | ||||
| import { updateContract } from './contracts' | ||||
| import { | ||||
|   coll, | ||||
|   getValue, | ||||
|  | @ -17,6 +16,7 @@ import { | |||
|   listenForValues, | ||||
| } from './utils' | ||||
| import { Contract } from 'common/contract' | ||||
| import { updateContract } from 'web/lib/firebase/contracts' | ||||
| 
 | ||||
| export const groups = coll<Group>('groups') | ||||
| 
 | ||||
|  | @ -52,6 +52,13 @@ export function listenForGroups(setGroups: (groups: Group[]) => void) { | |||
|   return listenForValues(groups, setGroups) | ||||
| } | ||||
| 
 | ||||
| export function listenForOpenGroups(setGroups: (groups: Group[]) => void) { | ||||
|   return listenForValues( | ||||
|     query(groups, where('anyoneCanJoin', '==', true)), | ||||
|     setGroups | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function getGroup(groupId: string) { | ||||
|   return getValue<Group>(doc(groups, groupId)) | ||||
| } | ||||
|  | @ -129,23 +136,23 @@ export async function addContractToGroup( | |||
|   contract: Contract, | ||||
|   userId: string | ||||
| ) { | ||||
|   if (!contract.groupLinks?.map((l) => l.groupId).includes(group.id)) { | ||||
|     const newGroupLinks = [ | ||||
|       ...(contract.groupLinks ?? []), | ||||
|       { | ||||
|         groupId: group.id, | ||||
|         createdTime: Date.now(), | ||||
|         slug: group.slug, | ||||
|         userId, | ||||
|         name: group.name, | ||||
|       } as GroupLink, | ||||
|     ] | ||||
|   if (!canModifyGroupContracts(group, userId)) return | ||||
|   const newGroupLinks = [ | ||||
|     ...(contract.groupLinks ?? []), | ||||
|     { | ||||
|       groupId: group.id, | ||||
|       createdTime: Date.now(), | ||||
|       slug: group.slug, | ||||
|       userId, | ||||
|       name: group.name, | ||||
|     } as GroupLink, | ||||
|   ] | ||||
|   // It's good to update the contract first, so the on-update-group trigger doesn't re-add them
 | ||||
|   await updateContract(contract.id, { | ||||
|     groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]), | ||||
|     groupLinks: newGroupLinks, | ||||
|   }) | ||||
| 
 | ||||
|     await updateContract(contract.id, { | ||||
|       groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]), | ||||
|       groupLinks: newGroupLinks, | ||||
|     }) | ||||
|   } | ||||
|   if (!group.contractIds.includes(contract.id)) { | ||||
|     return await updateGroup(group, { | ||||
|       contractIds: uniq([...group.contractIds, contract.id]), | ||||
|  | @ -160,8 +167,11 @@ export async function addContractToGroup( | |||
| 
 | ||||
| export async function removeContractFromGroup( | ||||
|   group: Group, | ||||
|   contract: Contract | ||||
|   contract: Contract, | ||||
|   userId: string | ||||
| ) { | ||||
|   if (!canModifyGroupContracts(group, userId)) return | ||||
| 
 | ||||
|   if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) { | ||||
|     const newGroupLinks = contract.groupLinks?.filter( | ||||
|       (link) => link.slug !== group.slug | ||||
|  | @ -186,29 +196,10 @@ export async function removeContractFromGroup( | |||
|   } | ||||
| } | ||||
| 
 | ||||
| export async function setContractGroupLinks( | ||||
|   group: Group, | ||||
|   contractId: string, | ||||
|   userId: string | ||||
| ) { | ||||
|   await updateContract(contractId, { | ||||
|     groupSlugs: [group.slug], | ||||
|     groupLinks: [ | ||||
|       { | ||||
|         groupId: group.id, | ||||
|         name: group.name, | ||||
|         slug: group.slug, | ||||
|         userId, | ||||
|         createdTime: Date.now(), | ||||
|       } as GroupLink, | ||||
|     ], | ||||
|   }) | ||||
|   return await updateGroup(group, { | ||||
|     contractIds: uniq([...group.contractIds, contractId]), | ||||
|   }) | ||||
|     .then(() => group) | ||||
|     .catch((err) => { | ||||
|       console.error('error adding contract to group', err) | ||||
|       return err | ||||
|     }) | ||||
| export function canModifyGroupContracts(group: Group, userId: string) { | ||||
|   return ( | ||||
|     group.creatorId === userId || | ||||
|     group.memberIds.includes(userId) || | ||||
|     group.anyoneCanJoin | ||||
|   ) | ||||
| } | ||||
|  |  | |||
|  | @ -96,22 +96,25 @@ const CACHED_REFERRAL_GROUP_ID_KEY = 'CACHED_REFERRAL_GROUP_KEY' | |||
| 
 | ||||
| export function writeReferralInfo( | ||||
|   defaultReferrerUsername: string, | ||||
|   contractId?: string, | ||||
|   referralUsername?: string, | ||||
|   groupId?: string | ||||
|   otherOptions?: { | ||||
|     contractId?: string | ||||
|     overwriteReferralUsername?: string | ||||
|     groupId?: string | ||||
|   } | ||||
| ) { | ||||
|   const local = safeLocalStorage() | ||||
|   const cachedReferralUser = local?.getItem(CACHED_REFERRAL_USERNAME_KEY) | ||||
|   const { contractId, overwriteReferralUsername, groupId } = otherOptions || {} | ||||
|   // Write the first referral username we see.
 | ||||
|   if (!cachedReferralUser) | ||||
|     local?.setItem( | ||||
|       CACHED_REFERRAL_USERNAME_KEY, | ||||
|       referralUsername || defaultReferrerUsername | ||||
|       overwriteReferralUsername || defaultReferrerUsername | ||||
|     ) | ||||
| 
 | ||||
|   // If an explicit referral query is passed, overwrite the cached referral username.
 | ||||
|   if (referralUsername) | ||||
|     local?.setItem(CACHED_REFERRAL_USERNAME_KEY, referralUsername) | ||||
|   if (overwriteReferralUsername) | ||||
|     local?.setItem(CACHED_REFERRAL_USERNAME_KEY, overwriteReferralUsername) | ||||
| 
 | ||||
|   // Always write the most recent explicit group invite query value
 | ||||
|   if (groupId) local?.setItem(CACHED_REFERRAL_GROUP_ID_KEY, groupId) | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ import Script from 'next/script' | |||
| import { usePreserveScroll } from 'web/hooks/use-preserve-scroll' | ||||
| import { QueryClient, QueryClientProvider } from 'react-query' | ||||
| import { AuthProvider } from 'web/components/auth-context' | ||||
| import Welcome from 'web/components/onboarding/welcome' | ||||
| 
 | ||||
| function firstLine(msg: string) { | ||||
|   return msg.replace(/\r?\n.*/s, '') | ||||
|  | @ -78,9 +79,9 @@ function MyApp({ Component, pageProps }: AppProps) { | |||
|           content="width=device-width, initial-scale=1, maximum-scale=1" | ||||
|         /> | ||||
|       </Head> | ||||
| 
 | ||||
|       <AuthProvider> | ||||
|         <QueryClientProvider client={queryClient}> | ||||
|           <Welcome {...pageProps} /> | ||||
|           <Component {...pageProps} /> | ||||
|         </QueryClientProvider> | ||||
|       </AuthProvider> | ||||
|  |  | |||
							
								
								
									
										18
									
								
								web/pages/api/v0/group/[slug].ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								web/pages/api/v0/group/[slug].ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| import { NextApiRequest, NextApiResponse } from 'next' | ||||
| import { getGroupBySlug } from 'web/lib/firebase/groups' | ||||
| import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' | ||||
| 
 | ||||
| export default async function handler( | ||||
|   req: NextApiRequest, | ||||
|   res: NextApiResponse | ||||
| ) { | ||||
|   await applyCorsHeaders(req, res, CORS_UNRESTRICTED) | ||||
|   const { slug } = req.query | ||||
|   const group = await getGroupBySlug(slug as string) | ||||
|   if (!group) { | ||||
|     res.status(404).json({ error: 'Group not found' }) | ||||
|     return | ||||
|   } | ||||
|   res.setHeader('Cache-Control', 'no-cache') | ||||
|   return res.status(200).json(group) | ||||
| } | ||||
							
								
								
									
										18
									
								
								web/pages/api/v0/group/by-id/[id].ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								web/pages/api/v0/group/by-id/[id].ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| import { NextApiRequest, NextApiResponse } from 'next' | ||||
| import { getGroup } from 'web/lib/firebase/groups' | ||||
| import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' | ||||
| 
 | ||||
| export default async function handler( | ||||
|   req: NextApiRequest, | ||||
|   res: NextApiResponse | ||||
| ) { | ||||
|   await applyCorsHeaders(req, res, CORS_UNRESTRICTED) | ||||
|   const { id } = req.query | ||||
|   const group = await getGroup(id as string) | ||||
|   if (!group) { | ||||
|     res.status(404).json({ error: 'Group not found' }) | ||||
|     return | ||||
|   } | ||||
|   res.setHeader('Cache-Control', 'no-cache') | ||||
|   return res.status(200).json(group) | ||||
| } | ||||
							
								
								
									
										15
									
								
								web/pages/api/v0/groups.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								web/pages/api/v0/groups.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,15 @@ | |||
| import type { NextApiRequest, NextApiResponse } from 'next' | ||||
| import { listAllGroups } from 'web/lib/firebase/groups' | ||||
| import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' | ||||
| 
 | ||||
| type Data = any[] | ||||
| 
 | ||||
| export default async function handler( | ||||
|   req: NextApiRequest, | ||||
|   res: NextApiResponse<Data> | ||||
| ) { | ||||
|   await applyCorsHeaders(req, res, CORS_UNRESTRICTED) | ||||
|   const groups = await listAllGroups() | ||||
|   res.setHeader('Cache-Control', 'max-age=0') | ||||
|   res.status(200).json(groups) | ||||
| } | ||||
							
								
								
									
										23
									
								
								web/pages/api/v0/market/[id]/lite.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								web/pages/api/v0/market/[id]/lite.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,23 @@ | |||
| import { NextApiRequest, NextApiResponse } from 'next' | ||||
| import { getContractFromId } from 'web/lib/firebase/contracts' | ||||
| import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' | ||||
| import { ApiError, toLiteMarket, LiteMarket } from '../../_types' | ||||
| 
 | ||||
| export default async function handler( | ||||
|   req: NextApiRequest, | ||||
|   res: NextApiResponse<LiteMarket | ApiError> | ||||
| ) { | ||||
|   await applyCorsHeaders(req, res, CORS_UNRESTRICTED) | ||||
|   const { id } = req.query | ||||
|   const contractId = id as string | ||||
| 
 | ||||
|   const contract = await getContractFromId(contractId) | ||||
| 
 | ||||
|   if (!contract) { | ||||
|     res.status(404).json({ error: 'Contract not found' }) | ||||
|     return | ||||
|   } | ||||
| 
 | ||||
|   res.setHeader('Cache-Control', 'max-age=0') | ||||
|   return res.status(200).json(toLiteMarket(contract)) | ||||
| } | ||||
							
								
								
									
										16
									
								
								web/pages/api/v0/me.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								web/pages/api/v0/me.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,16 @@ | |||
| import { NextApiRequest, NextApiResponse } from 'next' | ||||
| import { fetchBackend, forwardResponse } from 'web/lib/api/proxy' | ||||
| import { LiteUser, ApiError } from './_types' | ||||
| 
 | ||||
| export default async function handler( | ||||
|   req: NextApiRequest, | ||||
|   res: NextApiResponse<LiteUser | ApiError> | ||||
| ) { | ||||
|   try { | ||||
|     const backendRes = await fetchBackend(req, 'getcurrentuser') | ||||
|     await forwardResponse(res, backendRes) | ||||
|   } catch (err) { | ||||
|     console.error('Error talking to cloud function: ', err) | ||||
|     res.status(500).json({ error: 'Error communicating with backend.' }) | ||||
|   } | ||||
| } | ||||
|  | @ -102,7 +102,7 @@ export default function ContractSearchFirestore(props: { | |||
|         > | ||||
|           <option value="newest">Newest</option> | ||||
|           <option value="oldest">Oldest</option> | ||||
|           <option value="score">Most popular</option> | ||||
|           <option value="score">Trending</option> | ||||
|           <option value="most-traded">Most traded</option> | ||||
|           <option value="24-hour-vol">24h volume</option> | ||||
|           <option value="close-date">Closing soon</option> | ||||
|  |  | |||
|  | @ -19,7 +19,7 @@ import { | |||
| import { formatMoney } from 'common/util/format' | ||||
| import { removeUndefinedProps } from 'common/util/object' | ||||
| import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' | ||||
| import { getGroup, setContractGroupLinks } from 'web/lib/firebase/groups' | ||||
| import { canModifyGroupContracts, getGroup } from 'web/lib/firebase/groups' | ||||
| import { Group } from 'common/group' | ||||
| import { useTracking } from 'web/hooks/use-tracking' | ||||
| import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' | ||||
|  | @ -122,7 +122,7 @@ export function NewContract(props: { | |||
|   useEffect(() => { | ||||
|     if (groupId && creator) | ||||
|       getGroup(groupId).then((group) => { | ||||
|         if (group && group.memberIds.includes(creator.id)) { | ||||
|         if (group && canModifyGroupContracts(group, creator.id)) { | ||||
|           setSelectedGroup(group) | ||||
|           setShowGroupSelector(false) | ||||
|         } | ||||
|  | @ -239,10 +239,6 @@ export function NewContract(props: { | |||
|         selectedGroup: selectedGroup?.id, | ||||
|         isFree: false, | ||||
|       }) | ||||
|       if (result && selectedGroup) { | ||||
|         await setContractGroupLinks(selectedGroup, result.id, creator.id) | ||||
|       } | ||||
| 
 | ||||
|       await router.push(contractPath(result as Contract)) | ||||
|     } catch (e) { | ||||
|       console.error('error creating contract', e, (e as any).details) | ||||
|  | @ -477,6 +473,8 @@ export function NewContract(props: { | |||
|           {isSubmitting ? 'Creating...' : 'Create question'} | ||||
|         </button> | ||||
|       </Row> | ||||
| 
 | ||||
|       <Spacer h={6} /> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  |  | |||
|  | @ -16,7 +16,7 @@ import { Row } from 'web/components/layout/row' | |||
| import { UserLink } from 'web/components/user-page' | ||||
| import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' | ||||
| import { Col } from 'web/components/layout/col' | ||||
| import { useUser } from 'web/hooks/use-user' | ||||
| import { usePrivateUser, useUser } from 'web/hooks/use-user' | ||||
| import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' | ||||
| import { useRouter } from 'next/router' | ||||
| import { scoreCreators, scoreTraders } from 'common/scoring' | ||||
|  | @ -30,7 +30,7 @@ import { fromPropz, usePropz } from 'web/hooks/use-propz' | |||
| import { Tabs } from 'web/components/layout/tabs' | ||||
| import { CreateQuestionButton } from 'web/components/create-question-button' | ||||
| import React, { useState } from 'react' | ||||
| import { GroupChat } from 'web/components/groups/group-chat' | ||||
| import { GroupChatInBubble } from 'web/components/groups/group-chat' | ||||
| import { LoadingIndicator } from 'web/components/loading-indicator' | ||||
| import { Modal } from 'web/components/layout/modal' | ||||
| import { getSavedSort } from 'web/hooks/use-sort-and-query-params' | ||||
|  | @ -45,11 +45,12 @@ import { SearchIcon } from '@heroicons/react/outline' | |||
| import { useTipTxns } from 'web/hooks/use-tip-txns' | ||||
| import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' | ||||
| import { searchInAny } from 'common/util/parse' | ||||
| import { useWindowSize } from 'web/hooks/use-window-size' | ||||
| import { CopyLinkButton } from 'web/components/copy-link-button' | ||||
| import { ENV_CONFIG } from 'common/envs/constants' | ||||
| import { useSaveReferral } from 'web/hooks/use-save-referral' | ||||
| import { Button } from 'web/components/button' | ||||
| import { listAllCommentsOnGroup } from 'web/lib/firebase/comments' | ||||
| import { Comment } from 'common/comment' | ||||
| 
 | ||||
| export const getStaticProps = fromPropz(getStaticPropz) | ||||
| export async function getStaticPropz(props: { params: { slugs: string[] } }) { | ||||
|  | @ -65,6 +66,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { | |||
|   const bets = await Promise.all( | ||||
|     contracts.map((contract: Contract) => listAllBets(contract.id)) | ||||
|   ) | ||||
|   const messages = group && (await listAllCommentsOnGroup(group.id)) | ||||
| 
 | ||||
|   const creatorScores = scoreCreators(contracts) | ||||
|   const traderScores = scoreTraders(contracts, bets) | ||||
|  | @ -86,6 +88,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { | |||
|       topTraders, | ||||
|       creatorScores, | ||||
|       topCreators, | ||||
|       messages, | ||||
|     }, | ||||
| 
 | ||||
|     revalidate: 60, // regenerate after a minute
 | ||||
|  | @ -123,6 +126,7 @@ export default function GroupPage(props: { | |||
|   topTraders: User[] | ||||
|   creatorScores: { [userId: string]: number } | ||||
|   topCreators: User[] | ||||
|   messages: Comment[] | ||||
| }) { | ||||
|   props = usePropz(props, getStaticPropz) ?? { | ||||
|     group: null, | ||||
|  | @ -132,6 +136,7 @@ export default function GroupPage(props: { | |||
|     topTraders: [], | ||||
|     creatorScores: {}, | ||||
|     topCreators: [], | ||||
|     messages: [], | ||||
|   } | ||||
|   const { | ||||
|     creator, | ||||
|  | @ -149,19 +154,18 @@ export default function GroupPage(props: { | |||
|   const group = useGroup(props.group?.id) ?? props.group | ||||
|   const tips = useTipTxns({ groupId: group?.id }) | ||||
| 
 | ||||
|   const messages = useCommentsOnGroup(group?.id) | ||||
|   const messages = useCommentsOnGroup(group?.id) ?? props.messages | ||||
| 
 | ||||
|   const user = useUser() | ||||
|   const privateUser = usePrivateUser(user?.id) | ||||
| 
 | ||||
|   useSaveReferral(user, { | ||||
|     defaultReferrer: creator.username, | ||||
|     groupId: group?.id, | ||||
|   }) | ||||
| 
 | ||||
|   const { width } = useWindowSize() | ||||
|   const chatDisabled = !group || group.chatDisabled | ||||
|   const showChatSidebar = !chatDisabled && (width ?? 1280) >= 1280 | ||||
|   const showChatTab = !chatDisabled && !showChatSidebar | ||||
|   const showChatBubble = !chatDisabled | ||||
| 
 | ||||
|   if (group === null || !groupSubpages.includes(page) || slugs[2]) { | ||||
|     return <Custom404 /> | ||||
|  | @ -195,16 +199,6 @@ export default function GroupPage(props: { | |||
|     </Col> | ||||
|   ) | ||||
| 
 | ||||
|   const chatTab = ( | ||||
|     <Col className=""> | ||||
|       {messages ? ( | ||||
|         <GroupChat messages={messages} user={user} group={group} tips={tips} /> | ||||
|       ) : ( | ||||
|         <LoadingIndicator /> | ||||
|       )} | ||||
|     </Col> | ||||
|   ) | ||||
| 
 | ||||
|   const questionsTab = ( | ||||
|     <ContractSearch | ||||
|       querySortOptions={{ | ||||
|  | @ -217,15 +211,6 @@ export default function GroupPage(props: { | |||
|   ) | ||||
| 
 | ||||
|   const tabs = [ | ||||
|     ...(!showChatTab | ||||
|       ? [] | ||||
|       : [ | ||||
|           { | ||||
|             title: 'Chat', | ||||
|             content: chatTab, | ||||
|             href: groupPath(group.slug, GROUP_CHAT_SLUG), | ||||
|           }, | ||||
|         ]), | ||||
|     { | ||||
|       title: 'Markets', | ||||
|       content: questionsTab, | ||||
|  | @ -242,20 +227,17 @@ export default function GroupPage(props: { | |||
|       href: groupPath(group.slug, 'about'), | ||||
|     }, | ||||
|   ] | ||||
| 
 | ||||
|   const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG) | ||||
| 
 | ||||
|   return ( | ||||
|     <Page | ||||
|       rightSidebar={showChatSidebar ? chatTab : undefined} | ||||
|       rightSidebarClassName={showChatSidebar ? '!top-0' : ''} | ||||
|       className={showChatSidebar ? '!max-w-7xl !pb-0' : ''} | ||||
|     > | ||||
|     <Page> | ||||
|       <SEO | ||||
|         title={group.name} | ||||
|         description={`Created by ${creator.name}. ${group.about}`} | ||||
|         url={groupPath(group.slug)} | ||||
|       /> | ||||
|       <Col className="px-3"> | ||||
|       <Col className="relative px-3"> | ||||
|         <Row className={'items-center justify-between gap-4'}> | ||||
|           <div className={'sm:mb-1'}> | ||||
|             <div | ||||
|  | @ -282,6 +264,15 @@ export default function GroupPage(props: { | |||
|         defaultIndex={tabIndex > 0 ? tabIndex : 0} | ||||
|         tabs={tabs} | ||||
|       /> | ||||
|       {showChatBubble && ( | ||||
|         <GroupChatInBubble | ||||
|           group={group} | ||||
|           user={user} | ||||
|           privateUser={privateUser} | ||||
|           tips={tips} | ||||
|           messages={messages} | ||||
|         /> | ||||
|       )} | ||||
|     </Page> | ||||
|   ) | ||||
| } | ||||
|  |  | |||
|  | @ -81,11 +81,18 @@ const useContractPage = () => { | |||
|         if (!username || !contractSlug) setContract(undefined) | ||||
|         else { | ||||
|           // Show contract if route is to a contract: '/[username]/[contractSlug]'.
 | ||||
|           getContractFromSlug(contractSlug).then(setContract) | ||||
|           getContractFromSlug(contractSlug).then((contract) => { | ||||
|             const path = location.pathname.split('/').slice(1) | ||||
|             const [_username, contractSlug] = path | ||||
|             // Make sure we're still on the same contract.
 | ||||
|             if (contract?.slug === contractSlug) setContract(contract) | ||||
|           }) | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     addEventListener('popstate', updateContract) | ||||
| 
 | ||||
|     const { pushState, replaceState } = window.history | ||||
| 
 | ||||
|     window.history.pushState = function () { | ||||
|  | @ -101,6 +108,7 @@ const useContractPage = () => { | |||
|     } | ||||
| 
 | ||||
|     return () => { | ||||
|       removeEventListener('popstate', updateContract) | ||||
|       window.history.pushState = pushState | ||||
|       window.history.replaceState = replaceState | ||||
|     } | ||||
|  |  | |||
|  | @ -1,14 +1,17 @@ | |||
| import { useRouter } from 'next/router' | ||||
| import { useState } from 'react' | ||||
| import { useEffect, useState } from 'react' | ||||
| import { SEO } from 'web/components/SEO' | ||||
| import { Title } from 'web/components/title' | ||||
| import { claimManalink } from 'web/lib/firebase/api' | ||||
| import { useManalink } from 'web/lib/firebase/manalinks' | ||||
| import { ManalinkCard } from 'web/components/manalink-card' | ||||
| import { useUser } from 'web/hooks/use-user' | ||||
| import { firebaseLogin } from 'web/lib/firebase/users' | ||||
| import { firebaseLogin, getUser } from 'web/lib/firebase/users' | ||||
| import { Row } from 'web/components/layout/row' | ||||
| import { Button } from 'web/components/button' | ||||
| import { useSaveReferral } from 'web/hooks/use-save-referral' | ||||
| import { User } from 'common/user' | ||||
| import { Manalink } from 'common/manalink' | ||||
| 
 | ||||
| export default function ClaimPage() { | ||||
|   const user = useUser() | ||||
|  | @ -18,6 +21,8 @@ export default function ClaimPage() { | |||
|   const [claiming, setClaiming] = useState(false) | ||||
|   const [error, setError] = useState<string | undefined>(undefined) | ||||
| 
 | ||||
|   useReferral(user, manalink) | ||||
| 
 | ||||
|   if (!manalink) { | ||||
|     return <></> | ||||
|   } | ||||
|  | @ -33,46 +38,58 @@ export default function ClaimPage() { | |||
|       <div className="mx-auto max-w-xl px-2"> | ||||
|         <Row className="items-center justify-between"> | ||||
|           <Title text={`Claim M$${manalink.amount} mana`} /> | ||||
|           <div className="my-auto"> | ||||
|             <Button | ||||
|               onClick={async () => { | ||||
|                 setClaiming(true) | ||||
|                 try { | ||||
|                   if (user == null) { | ||||
|                     await firebaseLogin() | ||||
|                     setClaiming(false) | ||||
|                     return | ||||
|                   } | ||||
|                   if (user?.id == manalink.fromId) { | ||||
|                     throw new Error("You can't claim your own manalink.") | ||||
|                   } | ||||
|                   await claimManalink({ slug: manalink.slug }) | ||||
|                   user && router.push(`/${user.username}?claimed-mana=yes`) | ||||
|                 } catch (e) { | ||||
|                   console.log(e) | ||||
|                   const message = | ||||
|                     e && e instanceof Object | ||||
|                       ? e.toString() | ||||
|                       : 'An error occurred.' | ||||
|                   setError(message) | ||||
|                 } | ||||
|                 setClaiming(false) | ||||
|               }} | ||||
|               disabled={claiming} | ||||
|               size="lg" | ||||
|             > | ||||
|               {user ? 'Claim' : 'Login'} | ||||
|             </Button> | ||||
|           </div> | ||||
|           <div className="my-auto"></div> | ||||
|         </Row> | ||||
| 
 | ||||
|         <ManalinkCard info={info} /> | ||||
| 
 | ||||
|         {error && ( | ||||
|           <section className="my-5 text-red-500"> | ||||
|             <p>Failed to claim manalink.</p> | ||||
|             <p>{error}</p> | ||||
|           </section> | ||||
|         )} | ||||
| 
 | ||||
|         <Row className="items-center"> | ||||
|           <Button | ||||
|             onClick={async () => { | ||||
|               setClaiming(true) | ||||
|               try { | ||||
|                 if (user == null) { | ||||
|                   await firebaseLogin() | ||||
|                   setClaiming(false) | ||||
|                   return | ||||
|                 } | ||||
|                 if (user?.id == manalink.fromId) { | ||||
|                   throw new Error("You can't claim your own manalink.") | ||||
|                 } | ||||
|                 await claimManalink({ slug: manalink.slug }) | ||||
|                 user && router.push(`/${user.username}?claimed-mana=yes`) | ||||
|               } catch (e) { | ||||
|                 console.log(e) | ||||
|                 const message = | ||||
|                   e && e instanceof Object ? e.toString() : 'An error occurred.' | ||||
|                 setError(message) | ||||
|               } | ||||
|               setClaiming(false) | ||||
|             }} | ||||
|             disabled={claiming} | ||||
|             size="lg" | ||||
|           > | ||||
|             {user ? `Claim M$${manalink.amount}` : 'Login to claim'} | ||||
|           </Button> | ||||
|         </Row> | ||||
|       </div> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| const useReferral = (user: User | undefined | null, manalink?: Manalink) => { | ||||
|   const [creator, setCreator] = useState<User | undefined>(undefined) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (manalink?.fromId) getUser(manalink.fromId).then(setCreator) | ||||
|   }, [manalink]) | ||||
| 
 | ||||
|   useSaveReferral(user, { defaultReferrer: creator?.username }) | ||||
| } | ||||
|  |  | |||
|  | @ -1,4 +1,9 @@ | |||
| import { useState } from 'react' | ||||
| 
 | ||||
| import dayjs from 'dayjs' | ||||
| import customParseFormat from 'dayjs/plugin/customParseFormat' | ||||
| dayjs.extend(customParseFormat) | ||||
| 
 | ||||
| import { formatMoney } from 'common/util/format' | ||||
| import { Col } from 'web/components/layout/col' | ||||
| import { Row } from 'web/components/layout/row' | ||||
|  | @ -16,12 +21,10 @@ import { UserLink } from 'web/components/user-page' | |||
| import { CreateLinksButton } from 'web/components/manalinks/create-links-button' | ||||
| import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' | ||||
| 
 | ||||
| import dayjs from 'dayjs' | ||||
| import customParseFormat from 'dayjs/plugin/customParseFormat' | ||||
| import { ManalinkCardFromView } from 'web/components/manalink-card' | ||||
| import { Pagination } from 'web/components/pagination' | ||||
| import { Manalink } from 'common/manalink' | ||||
| dayjs.extend(customParseFormat) | ||||
| import { REFERRAL_AMOUNT } from 'common/user' | ||||
| 
 | ||||
| const LINKS_PER_PAGE = 24 | ||||
| export const getServerSideProps = redirectIfLoggedOut('/') | ||||
|  | @ -64,8 +67,10 @@ export default function LinkPage() { | |||
|           )} | ||||
|         </Row> | ||||
|         <p> | ||||
|           You can use manalinks to send mana to other people, even if they | ||||
|           don't yet have a Manifold account. | ||||
|           You can use manalinks to send mana (M$) to other people, even if they | ||||
|           don't yet have a Manifold account. Manalinks are also eligible | ||||
|           for the referral bonus. Invite a new user to Manifold and get M$ | ||||
|           {REFERRAL_AMOUNT} if they sign up! | ||||
|         </p> | ||||
|         <Subtitle text="Your Manalinks" /> | ||||
|         <ManalinksDisplay | ||||
|  |  | |||
							
								
								
									
										
											BIN
										
									
								
								web/public/welcome/charity.mp4
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/public/welcome/charity.mp4
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								web/public/welcome/mana-example.mp4
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/public/welcome/mana-example.mp4
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								web/public/welcome/manipurple.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/public/welcome/manipurple.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 20 KiB | 
							
								
								
									
										
											BIN
										
									
								
								web/public/welcome/treasure.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/public/welcome/treasure.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 41 KiB | 
|  | @ -18,6 +18,15 @@ module.exports = { | |||
|       backgroundImage: { | ||||
|         'world-trading': "url('/world-trading-background.webp')", | ||||
|       }, | ||||
|       colors: { | ||||
|         'greyscale-1': '#FBFBFF', | ||||
|         'greyscale-2': '#E7E7F4', | ||||
|         'greyscale-3': '#D8D8EB', | ||||
|         'greyscale-4': '#B1B1C7', | ||||
|         'greyscale-5': '#9191A7', | ||||
|         'greyscale-6': '#66667C', | ||||
|         'greyscale-7': '#111140', | ||||
|       }, | ||||
|       typography: { | ||||
|         quoteless: { | ||||
|           css: { | ||||
|  |  | |||
|  | @ -5144,6 +5144,11 @@ dayjs@1.10.7: | |||
|   resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468" | ||||
|   integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig== | ||||
| 
 | ||||
| dayjs@1.11.4: | ||||
|   version "1.11.4" | ||||
|   resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.4.tgz#3b3c10ca378140d8917e06ebc13a4922af4f433e" | ||||
|   integrity sha512-Zj/lPM5hOvQ1Bf7uAvewDaUcsJoI6JmNqmHhHl3nyumwe0XHwt8sWdOVAPACJzCebL8gQCi+K49w7iKWnGwX9g== | ||||
| 
 | ||||
| debug@2, debug@2.6.9, debug@^2.6.0, debug@^2.6.9: | ||||
|   version "2.6.9" | ||||
|   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	Block a user