This commit is contained in:
marsteralex 2022-08-04 11:10:04 -07:00
commit b98a6caee3
63 changed files with 2255 additions and 539 deletions

View File

@ -40,12 +40,14 @@ export type User = {
referredByContractId?: string referredByContractId?: string
referredByGroupId?: string referredByGroupId?: string
lastPingTime?: number lastPingTime?: number
shouldShowWelcome?: boolean
} }
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
// for sus users, i.e. multiple sign ups for same person // for sus users, i.e. multiple sign ups for same person
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10 export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500 export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
export type PrivateUser = { export type PrivateUser = {
id: string // same as User.id id: string // same as User.id
username: string // denormalized from User username: string // denormalized from User
@ -55,6 +57,7 @@ export type PrivateUser = {
unsubscribedFromCommentEmails?: boolean unsubscribedFromCommentEmails?: boolean
unsubscribedFromAnswerEmails?: boolean unsubscribedFromAnswerEmails?: boolean
unsubscribedFromGenericEmails?: boolean unsubscribedFromGenericEmails?: boolean
manaBonusEmailSent?: boolean
initialDeviceToken?: string initialDeviceToken?: string
initialIpAddress?: string initialIpAddress?: string
apiKey?: string apiKey?: string

View File

@ -46,6 +46,28 @@ Gets a user by their unique ID. Many other API endpoints return this as the `use
Requires no authorization. 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` ### `GET /v0/markets`
Lists all markets, ordered by creation date descending. 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, answer. For numeric markets, this is a string representing the target bucket,
and an additional `value` parameter is required which is a number representing and an additional `value` parameter is required which is a number representing
the target value. (Bet on numeric markets at your own peril.) 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: Example request:
@ -581,12 +617,12 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
### `POST /v0/market/[marketId]/sell` ### `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: Parameters:
- `outcome`: Required. One of `YES`, `NO`, or a `number` indicating the numeric - `outcome`: Optional. One of `YES`, or `NO`. If you leave it off, and you only
bucket ID, depending on the market type. own one kind of shares, you will sell that kind of shares.
- `shares`: Optional. The amount of shares to sell of the outcome given - `shares`: Optional. The amount of shares to sell of the outcome given
above. If not provided, all the shares you own will be sold. above. If not provided, all the shares you own will be sold.
@ -617,7 +653,7 @@ Requires no authorization.
- Example request - 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[]`. - Response type: A `Bet[]`.
@ -625,31 +661,60 @@ Requires no authorization.
```json ```json
[ [
// Limit bet, partially filled.
{ {
"probAfter": 0.44418877319153904, "isFilled": false,
"shares": -645.8346334931828, "amount": 15.596681605353808,
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
"contractId": "Tz5dA01GkK5QKiQfZeDL",
"probBefore": 0.5730753474948571,
"isCancelled": false,
"outcome": "YES", "outcome": "YES",
"contractId": "tgB1XmvFXZNhjr3xMNLp", "fees": { "creatorFee": 0, "liquidityFee": 0, "platformFee": 0 },
"sale": { "shares": 31.193363210707616,
"betId": "RcOtarI3d1DUUTjiE0rx", "limitProb": 0.5,
"amount": 474.9999999999998 "id": "yXB8lVbs86TKkhWA1FVi",
}, "loanAmount": 0,
"createdTime": 1644602886293, "orderAmount": 100,
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2", "probAfter": 0.5730753474948571,
"probBefore": 0.7229189477449224, "createdTime": 1659482775970,
"id": "x9eNmCaqQeXW8AgJ8Zmp", "fills": [
"amount": -499.9999999999998
},
{ {
"probAfter": 0.9901970375647697, "timestamp": 1659483249648,
"contractId": "zdeaYVAfHlo9jKzWh57J", "matchedBetId": "MfrMd5HTiGASDXzqibr7",
"outcome": "YES", "amount": 15.596681605353808,
"amount": 1, "shares": 31.193363210707616
"id": "8PqxKYwXCcLYoXy2m2Nm", }
"shares": 1.0049875638533763, ]
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2", },
"probBefore": 0.9900000000000001, // Normal bet (no limitProb specified).
"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"
} }
] ]
``` ```

View File

@ -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 - [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 - [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 ## API / Dev
@ -21,3 +22,4 @@ A list of community-created projects built on, or related to, Manifold Markets.
## Bots ## Bots
- [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon - [@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

View File

@ -22,7 +22,7 @@ service cloud.firestore {
allow read; allow read;
allow update: if resource.data.id == request.auth.uid allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && 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 // User referral rules
allow update: if resource.data.id == request.auth.uid allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()

View File

@ -31,6 +31,7 @@
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190", "@tiptap/starter-kit": "2.0.0-beta.190",
"dayjs": "1.11.4",
"cors": "2.8.5", "cors": "2.8.5",
"express": "4.18.1", "express": "4.18.1",
"firebase-admin": "10.0.0", "firebase-admin": "10.0.0",

View File

@ -14,7 +14,7 @@ import {
import { slugify } from '../../common/util/slugify' import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { chargeUser } from './utils' import { chargeUser, getContract } from './utils'
import { APIError, newEndpoint, validate, zTimestamp } from './api' import { APIError, newEndpoint, validate, zTimestamp } from './api'
import { import {
@ -28,11 +28,11 @@ import { Answer, getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract' import { getNewContract } from '../../common/new-contract'
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
import { User } from '../../common/user' 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 { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { zip } from 'lodash' import { uniq, zip } from 'lodash'
import { Bet } from 'common/bet' import { Bet } from '../../common/bet'
const descScehma: z.ZodType<JSONContent> = z.lazy(() => const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection( z.intersection(
@ -136,27 +136,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
const slug = await getSlug(question) const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc() 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( console.log(
'creating contract for', 'creating contract for',
user.username, user.username,
@ -188,6 +167,33 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
await contractRef.create(contract) 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 const providerId = user.id
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { 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) 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 ?? []),
],
})
}
}
}

View File

@ -1,5 +1,7 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { uniq } from 'lodash'
import { import {
MANIFOLD_AVATAR_URL, MANIFOLD_AVATAR_URL,
MANIFOLD_USERNAME, MANIFOLD_USERNAME,
@ -24,7 +26,6 @@ import {
import { track } from './analytics' import { track } from './analytics'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
import { uniq } from 'lodash'
import { import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
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 }, creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
followerCountCached: 0, followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES, followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true,
} }
await firestore.collection('users').doc(auth.uid).create(user) 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 firestore.collection('private-users').doc(auth.uid).create(privateUser)
await sendWelcomeEmail(user, privateUser)
await addUserToDefaultGroups(user) await addUserToDefaultGroups(user)
await sendWelcomeEmail(user, privateUser)
await track(auth.uid, 'create user', { username }, { ip: req.ip }) await track(auth.uid, 'create user', { username }, { ip: req.ip })
return user return user

File diff suppressed because one or more lines are too long

View 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"
>
&nbsp;
</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"
>
&nbsp;
</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"
>
&nbsp;
</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!&nbsp;</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&apos;s weekly active
users.</span
>
</li>
</ul>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
&nbsp;
</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>

View File

@ -165,7 +165,6 @@ export const sendWelcomeEmail = async (
) )
} }
// TODO: use manalinks to give out M$500
export const sendOneWeekBonusEmail = async ( export const sendOneWeekBonusEmail = async (
user: User, user: User,
privateUser: PrivateUser privateUser: PrivateUser
@ -185,12 +184,12 @@ export const sendOneWeekBonusEmail = async (
await sendTemplateEmail( await sendTemplateEmail(
privateUser.email, privateUser.email,
'Manifold one week anniversary gift', 'Manifold Markets one week anniversary gift',
'one-week', 'one-week',
{ {
name: firstName, name: firstName,
unsubscribeLink, unsubscribeLink,
manalink: '', // TODO manalink: 'https://manifold.markets/link/lj4JbBvE',
}, },
{ {
from: 'David from Manifold <david@manifold.markets>', from: 'David from Manifold <david@manifold.markets>',

View 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()

View File

@ -27,6 +27,25 @@ export * from './on-delete-group'
export * from './score-contracts' export * from './score-contracts'
// v2 // 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 { health } from './health'
import { transact } from './transact' import { transact } from './transact'
import { changeuserinfo } from './change-user-info' import { changeuserinfo } from './change-user-info'
@ -44,6 +63,7 @@ import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market' import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe' import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe' import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any) return onRequest(opts, handler as any)
@ -66,6 +86,7 @@ const resolveMarketFunction = toCloudFunction(resolvemarket)
const unsubscribeFunction = toCloudFunction(unsubscribe) const unsubscribeFunction = toCloudFunction(unsubscribe)
const stripeWebhookFunction = toCloudFunction(stripewebhook) const stripeWebhookFunction = toCloudFunction(stripewebhook)
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
export { export {
healthFunction as health, healthFunction as health,
@ -86,4 +107,5 @@ export {
unsubscribeFunction as unsubscribe, unsubscribeFunction as unsubscribe,
stripeWebhookFunction as stripewebhook, stripeWebhookFunction as stripewebhook,
createCheckoutSessionFunction as createcheckoutsession, createCheckoutSessionFunction as createcheckoutsession,
getCurrentUserFunction as getcurrentuser,
} }

View 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
}
}

View File

@ -1,6 +1,8 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { getContract } from './utils'
import { uniq } from 'lodash'
const firestore = admin.firestore() const firestore = admin.firestore()
export const onUpdateGroup = functions.firestore export const onUpdateGroup = functions.firestore
@ -9,7 +11,7 @@ export const onUpdateGroup = functions.firestore
const prevGroup = change.before.data() as Group const prevGroup = change.before.data() as Group
const group = change.after.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) if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
return return
@ -27,3 +29,23 @@ export const onUpdateGroup = functions.firestore
.doc(group.id) .doc(group.id)
.update({ mostRecentActivityTime: Date.now() }) .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
) ?? []),
],
})
}
}

View File

@ -18,6 +18,7 @@ import {
groupPayoutsByUser, groupPayoutsByUser,
Payout, Payout,
} from '../../common/payouts' } from '../../common/payouts'
import { isAdmin } from '../../common/envs/constants'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision' import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
@ -69,8 +70,6 @@ const opts = { secrets: ['MAILGUN_KEY'] }
export const resolvemarket = newEndpoint(opts, async (req, auth) => { export const resolvemarket = newEndpoint(opts, async (req, auth) => {
const { contractId } = validate(bodySchema, req.body) const { contractId } = validate(bodySchema, req.body)
const userId = auth.uid
const contractDoc = firestore.doc(`contracts/${contractId}`) const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await contractDoc.get() const contractSnap = await contractDoc.get()
if (!contractSnap.exists) if (!contractSnap.exists)
@ -83,7 +82,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
req.body req.body
) )
if (creatorId !== userId) if (creatorId !== auth.uid && !isAdmin(auth.uid))
throw new APIError(403, 'User is not creator of contract') throw new APIError(403, 'User is not creator of contract')
if (contract.resolution) throw new APIError(400, 'Contract already resolved') if (contract.resolution) throw new APIError(400, 'Contract already resolved')

View 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.`)
})
}

View File

@ -1,4 +1,4 @@
import { sumBy, uniq } from 'lodash' import { mapValues, groupBy, sumBy, uniq } from 'lodash'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
@ -9,7 +9,7 @@ import { getCpmmSellBetInfo } from '../../common/sell-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object' import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { getValues, log } from './utils' import { getValues, log } from './utils'
import { Bet } from '../../common/bet' 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 { getUnfilledBetsQuery, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore' import { FieldValue } from 'firebase-admin/firestore'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
@ -17,7 +17,7 @@ import { redeemShares } from './redeem-shares'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
shares: z.number().optional(), // leave it out to sell all shares 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) => { 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.') throw new APIError(400, 'Trading is closed.')
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0) 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) let chosenOutcome: 'YES' | 'NO'
const maxShares = sumBy(outcomeBets, (bet) => bet.shares) 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 const sharesToSell = shares ?? maxShares
if (!floatingLesserEqual(sharesToSell, maxShares)) if (!floatingLesserEqual(sharesToSell, maxShares))
@ -63,7 +85,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
soldShares, soldShares,
outcome, chosenOutcome,
contract, contract,
prevLoanAmount, prevLoanAmount,
unfilledBets unfilledBets

View File

@ -26,9 +26,10 @@ export const sendTemplateEmail = (
subject: string, subject: string,
templateId: string, templateId: string,
templateData: Record<string, 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>', from: options?.from ?? 'Manifold Markets <info@manifold.markets>',
to, to,
subject, subject,
@ -36,6 +37,7 @@ export const sendTemplateEmail = (
'h:X-Mailgun-Variables': JSON.stringify(templateData), 'h:X-Mailgun-Variables': JSON.stringify(templateData),
} }
const mg = initMailgun() const mg = initMailgun()
return mg.messages().send(data, (error) => { return mg.messages().send(data, (error) => {
if (error) console.log('Error sending email', error) if (error) console.log('Error sending email', error)
else console.log('Sent template email', templateId, to, subject) else console.log('Sent template email', templateId, to, subject)

View File

@ -25,6 +25,7 @@ import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market' import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe' import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe' import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
type Middleware = (req: Request, res: Response, next: NextFunction) => void type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express() const app = express()
@ -62,6 +63,7 @@ addJsonEndpointRoute('/creategroup', creategroup)
addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/unsubscribe', unsubscribe)
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
app.listen(PORT) app.listen(PORT)

View File

@ -157,9 +157,7 @@ export function BetsList(props: {
(c) => contractsMetrics[c.id].netPayout (c) => contractsMetrics[c.id].netPayout
) )
const totalPortfolio = currentNetInvestment + user.balance const totalPnl = user.profitCached.allTime
const totalPnl = totalPortfolio - user.totalDeposits
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100 const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
const investedProfitPercent = const investedProfitPercent =
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100 ((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
@ -354,7 +352,7 @@ function ContractBets(props: {
<LimitOrderTable <LimitOrderTable
contract={contract} contract={contract}
limitBets={limitBets} limitBets={limitBets}
isYou={true} isYou={isYourBets}
/> />
</div> </div>
)} )}

View File

@ -39,8 +39,10 @@ export function Button(props: {
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', 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' &&
color === 'gray-white' && 'bg-white text-gray-500 hover:bg-gray-200', 'bg-greyscale-1 text-greyscale-7 hover:bg-greyscale-2',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 bg-white',
className className
)} )}
disabled={disabled} disabled={disabled}

View File

@ -15,8 +15,8 @@ export function PillButton(props: {
className={clsx( className={clsx(
'cursor-pointer select-none whitespace-nowrap rounded-full', 'cursor-pointer select-none whitespace-nowrap rounded-full',
selected selected
? ['text-white', color ?? 'bg-gray-700'] ? ['text-white', color ?? 'bg-greyscale-6']
: 'bg-gray-100 hover:bg-gray-200', : 'bg-greyscale-2 hover:bg-greyscale-3',
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm' big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
)} )}
onClick={onSelect} onClick={onSelect}

View File

@ -1,26 +1,14 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import algoliasearch from 'algoliasearch/lite' import algoliasearch from 'algoliasearch/lite'
import {
Configure,
InstantSearch,
SearchBox,
SortBy,
useInfiniteHits,
useSortBy,
} from 'react-instantsearch-hooks-web'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params'
Sort,
useInitialQueryAndSort,
useUpdateQueryAndSort,
} from '../hooks/use-sort-and-query-params'
import { import {
ContractHighlightOptions, ContractHighlightOptions,
ContractsGrid, ContractsGrid,
} from './contract/contracts-list' } from './contract/contracts-list'
import { Row } from './layout/row' import { Row } from './layout/row'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useUser } from 'web/hooks/use-user' 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 { useMemberGroups } from 'web/hooks/use-group'
import { Group, NEW_USER_GROUP_SLUGS } from 'common/group' import { Group, NEW_USER_GROUP_SLUGS } from 'common/group'
import { PillButton } from './buttons/pill-button' import { PillButton } from './buttons/pill-button'
import { sortBy } from 'lodash' import { range, sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
import { Col } from './layout/col'
const searchClient = algoliasearch( const searchClient = algoliasearch(
'GJQPAYENIF', 'GJQPAYENIF',
@ -39,17 +28,17 @@ const searchClient = algoliasearch(
) )
const indexPrefix = ENV === 'DEV' ? 'dev-' : '' const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
const sortIndexes = [ const sortOptions = [
{ label: 'Newest', value: indexPrefix + 'contracts-newest' }, { label: 'Newest', value: 'newest' },
// { label: 'Oldest', value: indexPrefix + 'contracts-oldest' }, { label: 'Trending', value: 'score' },
{ label: 'Most popular', value: indexPrefix + 'contracts-score' }, { label: 'Most traded', value: 'most-traded' },
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' }, { label: '24h volume', value: '24-hour-vol' },
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' }, { label: 'Last updated', value: 'last-updated' },
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' }, { label: 'Subsidy', value: 'liquidity' },
{ label: 'Subsidy', value: indexPrefix + 'contracts-liquidity' }, { label: 'Close date', value: 'close-date' },
{ label: 'Close date', value: indexPrefix + 'contracts-close-date' }, { label: 'Resolve date', value: 'resolve-date' },
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
] ]
export const DEFAULT_SORT = 'score' export const DEFAULT_SORT = 'score'
@ -108,31 +97,27 @@ export function ContractSearch(props: {
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
const follows = useFollows(user?.id) const follows = useFollows(user?.id)
const { initialSort } = useInitialQueryAndSort(querySortOptions)
const sort = sortIndexes const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {}
.map(({ value }) => value) const { query, setQuery, sort, setSort } = useQueryAndSortParams({
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`) defaultSort,
? initialSort shouldLoadFromStorage,
: querySortOptions?.defaultSort ?? DEFAULT_SORT })
const [filter, setFilter] = useState<filter>( const [filter, setFilter] = useState<filter>(
querySortOptions?.defaultFilter ?? 'open' querySortOptions?.defaultFilter ?? 'open'
) )
const pillsEnabled = !additionalFilter const pillsEnabled = !additionalFilter && !query
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined) const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
const selectFilter = (pill: string | undefined) => () => { const selectPill = (pill: string | undefined) => () => {
setPillFilter(pill) setPillFilter(pill)
setPage(0)
track('select search category', { category: pill ?? 'all' }) track('select search category', { category: pill ?? 'all' })
} }
const { filters, numericFilters } = useMemo(() => { const additionalFilters = [
let filters = [
filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '',
additionalFilter?.creatorId additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}` ? `creatorId:${additionalFilter.creatorId}`
: '', : '',
@ -140,6 +125,14 @@ export function ContractSearch(props: {
additionalFilter?.groupSlug additionalFilter?.groupSlug
? `groupLinks.slug:${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' pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
? `groupLinks.slug:${pillFilter}` ? `groupLinks.slug:${pillFilter}`
: '', : '',
@ -161,24 +154,97 @@ export function ContractSearch(props: {
`uniqueBettorIds:${user.id}` `uniqueBettorIds:${user.id}`
: '', : '',
].filter((f) => f) ].filter((f) => f)
// Hack to make Algolia work.
filters = ['', ...filters]
const numericFilters = [ const numericFilters = query
? []
: [
filter === 'open' ? `closeTime > ${Date.now()}` : '', filter === 'open' ? `closeTime > ${Date.now()}` : '',
filter === 'closed' ? `closeTime <= ${Date.now()}` : '', filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
].filter((f) => f) ].filter((f) => f)
return { filters, numericFilters }
}, [
filter,
Object.values(additionalFilter ?? {}).join(','),
memberGroupSlugs.join(','),
(follows ?? []).join(','),
pillFilter,
])
const indexName = `${indexPrefix}contracts-${sort}` 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) { if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return ( return (
@ -190,44 +256,40 @@ export function ContractSearch(props: {
} }
return ( return (
<InstantSearch searchClient={searchClient} indexName={indexName}> <Col>
<Row className="gap-1 sm:gap-2"> <Row className="gap-1 sm:gap-2">
<SearchBox <input
className="flex-1" type="text"
placeholder={showPlaceHolder ? `Search ${filter} contracts` : ''} value={query}
classNames={{ onChange={(e) => updateQuery(e.target.value)}
form: 'before:top-6', placeholder={showPlaceHolder ? `Search ${filter} markets` : ''}
input: '!pl-10 !input !input-bordered shadow-none w-[100px]', className="input input-bordered w-full"
resetIcon: 'mt-2 hidden sm:flex',
}}
/> />
{/*// TODO track WHICH filter users are using*/} {!query && (
<select <select
className="!select !select-bordered" className="select select-bordered"
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value as filter)} onChange={(e) => selectFilter(e.target.value as filter)}
onBlur={trackCallback('select search filter', { filter })}
> >
<option value="open">Open</option> <option value="open">Open</option>
<option value="closed">Closed</option> <option value="closed">Closed</option>
<option value="resolved">Resolved</option> <option value="resolved">Resolved</option>
<option value="all">All</option> <option value="all">All</option>
</select> </select>
{!hideOrderSelector && (
<SortBy
items={sortIndexes}
classNames={{
select: '!select !select-bordered',
}}
onBlur={trackCallback('select search sort', { sort })}
/>
)} )}
<Configure {!hideOrderSelector && !query && (
facetFilters={filters} <select
numericFilters={numericFilters} className="select select-bordered"
// Page resets on filters change. value={sort}
page={0} onChange={(e) => selectSort(e.target.value as Sort)}
/> >
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
</Row> </Row>
<Spacer h={3} /> <Spacer h={3} />
@ -237,14 +299,14 @@ export function ContractSearch(props: {
<PillButton <PillButton
key={'all'} key={'all'}
selected={pillFilter === undefined} selected={pillFilter === undefined}
onSelect={selectFilter(undefined)} onSelect={selectPill(undefined)}
> >
All All
</PillButton> </PillButton>
<PillButton <PillButton
key={'personal'} key={'personal'}
selected={pillFilter === 'personal'} selected={pillFilter === 'personal'}
onSelect={selectFilter('personal')} onSelect={selectPill('personal')}
> >
{user ? 'For you' : 'Featured'} {user ? 'For you' : 'Featured'}
</PillButton> </PillButton>
@ -253,7 +315,7 @@ export function ContractSearch(props: {
<PillButton <PillButton
key={'your-bets'} key={'your-bets'}
selected={pillFilter === 'your-bets'} selected={pillFilter === 'your-bets'}
onSelect={selectFilter('your-bets')} onSelect={selectPill('your-bets')}
> >
Your bets Your bets
</PillButton> </PillButton>
@ -264,7 +326,7 @@ export function ContractSearch(props: {
<PillButton <PillButton
key={slug} key={slug}
selected={pillFilter === slug} selected={pillFilter === slug}
onSelect={selectFilter(slug)} onSelect={selectPill(slug)}
> >
{name} {name}
</PillButton> </PillButton>
@ -280,103 +342,17 @@ export function ContractSearch(props: {
memberGroupSlugs.length === 0 ? ( memberGroupSlugs.length === 0 ? (
<>You're not following anyone, nor in any of your own groups yet.</> <>You're not following anyone, nor in any of your own groups yet.</>
) : ( ) : (
<ContractSearchInner
querySortOptions={querySortOptions}
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 <ContractsGrid
contracts={contracts} contracts={contracts}
loadMore={showMore} loadMore={loadMore}
hasMore={!isLastPage} hasMore={true}
showTime={showTime} showTime={showTime}
onContractClick={onContractClick} onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName} overrideGridClassName={overrideGridClassName}
highlightOptions={highlightOptions} highlightOptions={highlightOptions}
cardHideOptions={cardHideOptions} cardHideOptions={cardHideOptions}
/> />
)}
</Col>
) )
} }

View File

@ -30,7 +30,7 @@ import { useContractWithPreload } from 'web/hooks/use-contract'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { formatNumericProbability } from 'common/pseudo-numeric' import { getMappedValue } from 'common/pseudo-numeric'
export function ContractCard(props: { export function ContractCard(props: {
contract: Contract contract: Contract
@ -115,7 +115,8 @@ export function ContractCard(props: {
{question} {question}
</p> </p>
{outcomeType === 'FREE_RESPONSE' && {(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') &&
(resolution ? ( (resolution ? (
<FreeResponseOutcomeLabel <FreeResponseOutcomeLabel
contract={contract} contract={contract}
@ -158,7 +159,8 @@ export function ContractCard(props: {
/> />
)} )}
{outcomeType === 'FREE_RESPONSE' && ( {(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<FreeResponseResolutionOrChance <FreeResponseResolutionOrChance
className="self-end text-gray-600" className="self-end text-gray-600"
contract={contract} contract={contract}
@ -210,7 +212,7 @@ export function BinaryResolutionOrChance(props: {
} }
function FreeResponseTopAnswer(props: { function FreeResponseTopAnswer(props: {
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract
truncate: 'short' | 'long' | 'none' truncate: 'short' | 'long' | 'none'
className?: string className?: string
}) { }) {
@ -315,6 +317,12 @@ export function PseudoNumericResolutionOrExpectation(props: {
const { resolution, resolutionValue, resolutionProbability } = contract const { resolution, resolutionValue, resolutionProbability } = contract
const textColor = `text-blue-400` const textColor = `text-blue-400`
const value = resolution
? resolutionValue
? resolutionValue
: getMappedValue(contract)(resolutionProbability ?? 0)
: getMappedValue(contract)(getProbability(contract))
return ( return (
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}> <Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
{resolution ? ( {resolution ? (
@ -324,20 +332,21 @@ export function PseudoNumericResolutionOrExpectation(props: {
{resolution === 'CANCEL' ? ( {resolution === 'CANCEL' ? (
<CancelLabel /> <CancelLabel />
) : ( ) : (
<div className="text-blue-400"> <div
{resolutionValue className={clsx('tooltip', textColor)}
? formatLargeNumber(resolutionValue) data-tip={value.toFixed(2)}
: formatNumericProbability( >
resolutionProbability ?? 0, {formatLargeNumber(value)}
contract
)}
</div> </div>
)} )}
</> </>
) : ( ) : (
<> <>
<div className={clsx('text-3xl', textColor)}> <div
{formatNumericProbability(getProbability(contract), contract)} className={clsx('tooltip text-3xl', textColor)}
data-tip={value.toFixed(2)}
>
{formatLargeNumber(value)}
</div> </div>
<div className={clsx('text-base', textColor)}>expected</div> <div className={clsx('text-base', textColor)}>expected</div>
</> </>

View File

@ -23,6 +23,9 @@ export function ContractTabs(props: {
const { outcomeType } = contract const { outcomeType } = contract
const userBets = user && bets.filter((bet) => bet.userId === user.id) 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 // Load comments here, so the badge count will be correct
const updatedComments = useComments(contract.id) const updatedComments = useComments(contract.id)
@ -99,7 +102,7 @@ export function ContractTabs(props: {
content: commentActivity, content: commentActivity,
badge: `${comments.length}`, badge: `${comments.length}`,
}, },
{ title: 'Bets', content: betActivity, badge: `${bets.length}` }, { title: 'Bets', content: betActivity, badge: `${visibleBets.length}` },
...(!user || !userBets?.length ...(!user || !userBets?.length
? [] ? []
: [{ title: 'Your bets', content: yourTrades }]), : [{ title: 'Your bets', content: yourTrades }]),

View File

@ -49,6 +49,10 @@ function duplicateContractHref(contract: Contract) {
params.initValue = getMappedValue(contract)(contract.initialProbability) params.initValue = getMappedValue(contract)(contract.initialProbability)
} }
if (contract.groupLinks && contract.groupLinks.length > 0) {
params.groupId = contract.groupLinks[0].groupId
}
return ( return (
`/create?` + `/create?` +
Object.entries(params) Object.entries(params)

View File

@ -7,6 +7,7 @@ import { Button } from 'web/components/button'
import { GroupSelector } from 'web/components/groups/group-selector' import { GroupSelector } from 'web/components/groups/group-selector'
import { import {
addContractToGroup, addContractToGroup,
canModifyGroupContracts,
removeContractFromGroup, removeContractFromGroup,
} from 'web/lib/firebase/groups' } from 'web/lib/firebase/groups'
import { User } from 'common/user' import { User } from 'common/user'
@ -57,11 +58,11 @@ export function ContractGroupsList(props: {
<Row className="line-clamp-1 items-center gap-2"> <Row className="line-clamp-1 items-center gap-2">
<GroupLinkItem group={group} /> <GroupLinkItem group={group} />
</Row> </Row>
{user && group.memberIds.includes(user.id) && ( {user && canModifyGroupContracts(group, user.id) && (
<Button <Button
color={'gray-white'} color={'gray-white'}
size={'xs'} size={'xs'}
onClick={() => removeContractFromGroup(group, contract)} onClick={() => removeContractFromGroup(group, contract, user.id)}
> >
<XIcon className="h-4 w-4 text-gray-500" /> <XIcon className="h-4 w-4 text-gray-500" />
</Button> </Button>

View File

@ -46,7 +46,7 @@ export function CreateGroupButton(props: {
const newGroup = { const newGroup = {
name: groupName, name: groupName,
memberIds: memberUsers.map((user) => user.id), memberIds: memberUsers.map((user) => user.id),
anyoneCanJoin: false, anyoneCanJoin: true,
} }
const result = await createGroup(newGroup).catch((e) => { const result = await createGroup(newGroup).catch((e) => {
const errorDetails = e.details[0] const errorDetails = e.details[0]

View File

@ -1,6 +1,6 @@
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Col } from 'web/components/layout/col' 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 React, { useEffect, memo, useState, useMemo } from 'react'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
import { Group } from 'common/group' import { Group } from 'common/group'
@ -23,6 +23,9 @@ import { Tipper } from 'web/components/tipper'
import { sum } from 'lodash' import { sum } from 'lodash'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useWindowSize } from 'web/hooks/use-window-size' 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: { export function GroupChat(props: {
messages: Comment[] messages: Comment[]
@ -44,6 +47,13 @@ export function GroupChat(props: {
const router = useRouter() const router = useRouter()
const isMember = user && group.memberIds.includes(user?.id) 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(() => { useMemo(() => {
// Group messages with createdTime within 2 minutes of each other. // Group messages with createdTime within 2 minutes of each other.
const tempMessages = [] const tempMessages = []
@ -70,9 +80,10 @@ export function GroupChat(props: {
}, [scrollToMessageRef]) }, [scrollToMessageRef])
useEffect(() => { useEffect(() => {
if (!isSubmitting) if (scrollToBottomRef)
scrollToBottomRef?.scrollTo({ top: scrollToBottomRef?.scrollHeight || 0 }) scrollToBottomRef.scrollTo({ top: scrollToBottomRef.scrollHeight || 0 })
}, [scrollToBottomRef, isSubmitting]) // Must also listen to groupedMessages as they update the height of the messaging window
}, [scrollToBottomRef, groupedMessages])
useEffect(() => { useEffect(() => {
const elementInUrl = router.asPath.split('#')[1] const elementInUrl = router.asPath.split('#')[1]
@ -81,6 +92,11 @@ export function GroupChat(props: {
} }
}, [messages, router.asPath]) }, [messages, router.asPath])
useEffect(() => {
// is mobile?
if (inputRef && width && width > 720) inputRef.focus()
}, [inputRef, width])
function onReplyClick(comment: Comment) { function onReplyClick(comment: Comment) {
setReplyToUsername(comment.userUsername) setReplyToUsername(comment.userUsername)
} }
@ -98,18 +114,6 @@ export function GroupChat(props: {
setReplyToUsername('') setReplyToUsername('')
inputRef?.focus() 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 ( return (
<Col ref={setContainerRef} style={{ height: remainingHeight }}> <Col ref={setContainerRef} style={{ height: remainingHeight }}>
@ -140,7 +144,7 @@ export function GroupChat(props: {
No messages yet. Why not{isMember ? ` ` : ' join and '} No messages yet. Why not{isMember ? ` ` : ' join and '}
<button <button
className={'cursor-pointer font-bold text-gray-700'} className={'cursor-pointer font-bold text-gray-700'}
onClick={() => focusInput()} onClick={() => inputRef?.focus()}
> >
add one? add one?
</button> </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: { const GroupMessage = memo(function GroupMessage_(props: {
user: User | null | undefined user: User | null | undefined
comment: Comment comment: Comment

View File

@ -9,7 +9,7 @@ import {
import clsx from 'clsx' import clsx from 'clsx'
import { CreateGroupButton } from 'web/components/groups/create-group-button' import { CreateGroupButton } from 'web/components/groups/create-group-button'
import { useState } from 'react' 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 { User } from 'common/user'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
@ -27,10 +27,15 @@ export function GroupSelector(props: {
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false) const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
const { showSelector, showLabel, ignoreGroupIds } = options const { showSelector, showLabel, ignoreGroupIds } = options
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const memberGroups = (useMemberGroups(creator?.id) ?? []).filter( const openGroups = useOpenGroups()
(group) => !ignoreGroupIds?.includes(group.id) const availableGroups = openGroups
.concat(
(useMemberGroups(creator?.id) ?? []).filter(
(g) => !openGroups.map((og) => og.id).includes(g.id)
) )
const filteredGroups = memberGroups.filter((group) => )
.filter((group) => !ignoreGroupIds?.includes(group.id))
const filteredGroups = availableGroups.filter((group) =>
searchInAny(query, group.name) searchInAny(query, group.name)
) )

View File

@ -1,15 +1,18 @@
import { useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { QrcodeIcon } from '@heroicons/react/outline'
import { DotsHorizontalIcon } from '@heroicons/react/solid'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { fromNow } from 'web/lib/util/time' import { fromNow } from 'web/lib/util/time'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Claim, Manalink } from 'common/manalink' import { Claim, Manalink } from 'common/manalink'
import { useState } from 'react'
import { ShareIconButton } from './share-icon-button' import { ShareIconButton } from './share-icon-button'
import { DotsHorizontalIcon } from '@heroicons/react/solid'
import { contractDetailsButtonClassName } from './contract/contract-info-dialog' import { contractDetailsButtonClassName } from './contract/contract-info-dialog'
import { useUserById } from 'web/hooks/use-user' import { useUserById } from 'web/hooks/use-user'
import getManalinkUrl from 'web/get-manalink-url' import getManalinkUrl from 'web/get-manalink-url'
export type ManalinkInfo = { export type ManalinkInfo = {
expiresTime: number | null expiresTime: number | null
maxUses: number | null maxUses: number | null
@ -78,7 +81,9 @@ export function ManalinkCardFromView(props: {
const { className, link, highlightedSlug } = props const { className, link, highlightedSlug } = props
const { message, amount, expiresTime, maxUses, claims } = link const { message, amount, expiresTime, maxUses, claims } = link
const [showDetails, setShowDetails] = useState(false) const [showDetails, setShowDetails] = useState(false)
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${200}x${200}&data=${getManalinkUrl(
link.slug
)}`
return ( return (
<Col> <Col>
<Col <Col
@ -127,6 +132,14 @@ export function ManalinkCardFromView(props: {
> >
{formatMoney(amount)} {formatMoney(amount)}
</div> </div>
<button
onClick={() => (window.location.href = qrUrl)}
className={clsx(contractDetailsButtonClassName)}
>
<QrcodeIcon className="h-6 w-6" />
</button>
<ShareIconButton <ShareIconButton
toastClassName={'-left-48 min-w-[250%]'} toastClassName={'-left-48 min-w-[250%]'}
buttonClassName={'transition-colors'} buttonClassName={'transition-colors'}

View File

@ -12,6 +12,7 @@ import dayjs from 'dayjs'
import { Button } from '../button' import { Button } from '../button'
import { getManalinkUrl } from 'web/pages/links' import { getManalinkUrl } from 'web/pages/links'
import { DuplicateIcon } from '@heroicons/react/outline' import { DuplicateIcon } from '@heroicons/react/outline'
import { QRCode } from '../qr-code'
export function CreateLinksButton(props: { export function CreateLinksButton(props: {
user: User user: User
@ -98,6 +99,8 @@ function CreateManalinkForm(props: {
}) })
} }
const url = getManalinkUrl(highlightedSlug)
return ( return (
<> <>
{!finishedCreating && ( {!finishedCreating && (
@ -199,17 +202,17 @@ function CreateManalinkForm(props: {
copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : '' copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : ''
)} )}
> >
<div className="w-full select-text truncate"> <div className="w-full select-text truncate">{url}</div>
{getManalinkUrl(highlightedSlug)}
</div>
<DuplicateIcon <DuplicateIcon
onClick={() => { onClick={() => {
navigator.clipboard.writeText(getManalinkUrl(highlightedSlug)) navigator.clipboard.writeText(url)
setCopyPressed(true) setCopyPressed(true)
}} }}
className="my-auto ml-2 h-5 w-5 cursor-pointer transition hover:opacity-50" className="my-auto ml-2 h-5 w-5 cursor-pointer transition hover:opacity-50"
/> />
</Row> </Row>
<QRCode url={url} className="self-center" />
</> </>
)} )}
</> </>

View File

@ -18,7 +18,7 @@ import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu' import { MenuButton } from './menu'
import { ProfileSummary } from './profile-menu' import { ProfileSummary } from './profile-menu'
import NotificationsIcon from 'web/components/notifications-icon' 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 { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button' import { CreateQuestionButton } from 'web/components/create-question-button'
import { useMemberGroups } from 'web/hooks/use-group' 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 { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { setNotificationsAsSeen } from 'web/pages/notifications'
import { PrivateUser } from 'common/user' import { PrivateUser } from 'common/user'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
@ -216,7 +215,7 @@ export default function Sidebar(props: { className?: string }) {
) ?? [] ) ?? []
).map((group: Group) => ({ ).map((group: Group) => ({
name: group.name, name: group.name,
href: `${groupPath(group.slug)}/${GROUP_CHAT_SLUG}`, href: `${groupPath(group.slug)}`,
})) }))
return ( return (
@ -294,30 +293,22 @@ function GroupsList(props: {
memberItems.length > 0 ? memberItems.length : undefined 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 { height } = useWindowSize()
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const remainingHeight = const remainingHeight =
(height ?? window.innerHeight) - (containerRef?.offsetTop ?? 0) (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 ( return (
<> <>
<SidebarItem <SidebarItem
@ -332,19 +323,19 @@ function GroupsList(props: {
> >
{memberItems.map((item) => ( {memberItems.map((item) => (
<a <a
key={item.href} href={
href={item.href} item.href +
(notifIsForThisItem(item.href) ? '/' + GROUP_CHAT_SLUG : '')
}
key={item.name}
onClick={trackCallback('sidebar: ' + item.name)}
className={clsx( 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', '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( notifIsForThisItem(item.href) && 'font-bold'
(n) =>
!n.isSeen &&
(n.isSeenOnHref === item.href ||
n.isSeenOnHref === item.href.replace('/chat', ''))
) && 'font-bold'
)} )}
> >
<span className="truncate">{item.name}</span> {item.name}
</a> </a>
))} ))}
</div> </div>

View 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, youll 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, weve sent you{' '}
<span className="font-normal text-indigo-700">M$1000 Mana</span>{' '}
</p>
</>
)
}

View File

@ -1,4 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import { Spacer } from './layout/spacer'
export function Pagination(props: { export function Pagination(props: {
page: number page: number
@ -23,6 +24,8 @@ export function Pagination(props: {
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1 const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
if (maxPage === 0) return <Spacer h={4} />
return ( return (
<nav <nav
className={clsx( className={clsx(

View 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} />
}

View File

@ -5,7 +5,7 @@ export function Title(props: { text: string; className?: string }) {
return ( return (
<h1 <h1
className={clsx( 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 className
)} )}
> >

View File

@ -214,6 +214,10 @@ export function UserPage(props: { user: User; currentUser?: User }) {
<Row className="gap-4"> <Row className="gap-4">
<FollowingButton user={user} /> <FollowingButton user={user} />
<FollowersButton user={user} /> <FollowersButton user={user} />
{currentUser &&
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
currentUser.username
) && <ReferralsButton user={user} />}
<GroupsButton user={user} /> <GroupsButton user={user} />
</Row> </Row>

View File

@ -5,6 +5,7 @@ import {
listenForGroup, listenForGroup,
listenForGroups, listenForGroups,
listenForMemberGroups, listenForMemberGroups,
listenForOpenGroups,
listGroups, listGroups,
} from 'web/lib/firebase/groups' } from 'web/lib/firebase/groups'
import { getUser, getUsers } from 'web/lib/firebase/users' import { getUser, getUsers } from 'web/lib/firebase/users'
@ -32,6 +33,16 @@ export const useGroups = () => {
return groups return groups
} }
export const useOpenGroups = () => {
const [groups, setGroups] = useState<Group[]>([])
useEffect(() => {
return listenForOpenGroups(setGroups)
}, [])
return groups
}
export const useMemberGroups = ( export const useMemberGroups = (
userId: string | null | undefined, userId: string | null | undefined,
options?: { withChatEnabled: boolean }, options?: { withChatEnabled: boolean },

View File

@ -18,10 +18,14 @@ export const useSaveReferral = (
referrer?: string referrer?: string
} }
const actualReferrer = referrer || options?.defaultReferrer const referrerOrDefault = referrer || options?.defaultReferrer
if (!user && router.isReady && actualReferrer) { if (!user && router.isReady && referrerOrDefault) {
writeReferralInfo(actualReferrer, options?.contractId, options?.groupId) writeReferralInfo(referrerOrDefault, {
contractId: options?.contractId,
overwriteReferralUsername: referrer,
groupId: options?.groupId,
})
} }
}, [user, router, options]) }, [user, router, options])
} }

View File

@ -1,8 +1,6 @@
import { defaults, debounce } from 'lodash' import { defaults, debounce } from 'lodash'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react' 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' import { DEFAULT_SORT } from 'web/components/contract-search'
const MARKETS_SORT = 'markets_sort' const MARKETS_SORT = 'markets_sort'
@ -74,15 +72,20 @@ export function useInitialQueryAndSort(options?: {
} }
} }
export function useUpdateQueryAndSort(props: { export function useQueryAndSortParams(options?: {
shouldLoadFromStorage: boolean defaultSort?: Sort
shouldLoadFromStorage?: boolean
}) { }) {
const { shouldLoadFromStorage } = props const { defaultSort = DEFAULT_SORT, shouldLoadFromStorage = true } =
options ?? {}
const router = useRouter() const router = useRouter()
const { s: sort, q: query } = router.query as {
q?: string
s?: Sort
}
const setSort = (sort: Sort | undefined) => { const setSort = (sort: Sort | undefined) => {
if (sort !== router.query.s) {
router.query.s = sort
router.replace({ query: { ...router.query, s: sort } }, undefined, { router.replace({ query: { ...router.query, s: sort } }, undefined, {
shallow: true, shallow: true,
}) })
@ -90,35 +93,50 @@ export function useUpdateQueryAndSort(props: {
localStorage.setItem(MARKETS_SORT, sort || '') localStorage.setItem(MARKETS_SORT, sort || '')
} }
} }
}
const { query, refine } = useSearchBox() const [queryState, setQueryState] = useState(query)
useEffect(() => {
setQueryState(query)
}, [query])
// Debounce router query update. // Debounce router query update.
const pushQuery = useMemo( const pushQuery = useMemo(
() => () =>
debounce((query: string | undefined) => { debounce((query: string | undefined) => {
if (query) { const queryObj = { ...router.query, q: query }
router.query.q = query if (!query) delete queryObj.q
} else { router.replace({ query: queryObj }, undefined, {
delete router.query.q
}
router.replace({ query: router.query }, undefined, {
shallow: true, shallow: true,
}) })
track('search', { query }) }, 100),
}, 500),
[router] [router]
) )
const setQuery = (query: string | undefined) => { const setQuery = (query: string | undefined) => {
refine(query ?? '') setQueryState(query)
pushQuery(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 { return {
sort: sort ?? defaultSort,
query: queryState ?? '',
setSort, setSort,
setQuery, setQuery,
query,
} }
} }

View File

@ -80,3 +80,7 @@ export function claimManalink(params: any) {
export function createGroup(params: any) { export function createGroup(params: any) {
return call(getFunctionUrl('creategroup'), 'POST', params) return call(getFunctionUrl('creategroup'), 'POST', params)
} }
export function getCurrentUser(params: any) {
return call(getFunctionUrl('getcurrentuser'), 'GET', params)
}

View File

@ -81,6 +81,7 @@ export async function createCommentOnGroup(
function getCommentsCollection(contractId: string) { function getCommentsCollection(contractId: string) {
return collection(db, 'contracts', contractId, 'comments') return collection(db, 'contracts', contractId, 'comments')
} }
function getCommentsOnGroupCollection(groupId: string) { function getCommentsOnGroupCollection(groupId: string) {
return collection(db, 'groups', groupId, 'comments') return collection(db, 'groups', groupId, 'comments')
} }
@ -91,6 +92,14 @@ export async function listAllComments(contractId: string) {
return comments 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( export function listenForCommentsOnContract(
contractId: string, contractId: string,
setComments: (comments: Comment[]) => void setComments: (comments: Comment[]) => void

View File

@ -8,7 +8,6 @@ import {
} from 'firebase/firestore' } from 'firebase/firestore'
import { sortBy, uniq } from 'lodash' import { sortBy, uniq } from 'lodash'
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group' import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
import { updateContract } from './contracts'
import { import {
coll, coll,
getValue, getValue,
@ -17,6 +16,7 @@ import {
listenForValues, listenForValues,
} from './utils' } from './utils'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { updateContract } from 'web/lib/firebase/contracts'
export const groups = coll<Group>('groups') export const groups = coll<Group>('groups')
@ -52,6 +52,13 @@ export function listenForGroups(setGroups: (groups: Group[]) => void) {
return listenForValues(groups, setGroups) return listenForValues(groups, setGroups)
} }
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {
return listenForValues(
query(groups, where('anyoneCanJoin', '==', true)),
setGroups
)
}
export function getGroup(groupId: string) { export function getGroup(groupId: string) {
return getValue<Group>(doc(groups, groupId)) return getValue<Group>(doc(groups, groupId))
} }
@ -129,7 +136,7 @@ export async function addContractToGroup(
contract: Contract, contract: Contract,
userId: string userId: string
) { ) {
if (!contract.groupLinks?.map((l) => l.groupId).includes(group.id)) { if (!canModifyGroupContracts(group, userId)) return
const newGroupLinks = [ const newGroupLinks = [
...(contract.groupLinks ?? []), ...(contract.groupLinks ?? []),
{ {
@ -140,12 +147,12 @@ export async function addContractToGroup(
name: group.name, name: group.name,
} as GroupLink, } 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, { await updateContract(contract.id, {
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]), groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
groupLinks: newGroupLinks, groupLinks: newGroupLinks,
}) })
}
if (!group.contractIds.includes(contract.id)) { if (!group.contractIds.includes(contract.id)) {
return await updateGroup(group, { return await updateGroup(group, {
contractIds: uniq([...group.contractIds, contract.id]), contractIds: uniq([...group.contractIds, contract.id]),
@ -160,8 +167,11 @@ export async function addContractToGroup(
export async function removeContractFromGroup( export async function removeContractFromGroup(
group: Group, group: Group,
contract: Contract contract: Contract,
userId: string
) { ) {
if (!canModifyGroupContracts(group, userId)) return
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) { if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
const newGroupLinks = contract.groupLinks?.filter( const newGroupLinks = contract.groupLinks?.filter(
(link) => link.slug !== group.slug (link) => link.slug !== group.slug
@ -186,29 +196,10 @@ export async function removeContractFromGroup(
} }
} }
export async function setContractGroupLinks( export function canModifyGroupContracts(group: Group, userId: string) {
group: Group, return (
contractId: string, group.creatorId === userId ||
userId: string group.memberIds.includes(userId) ||
) { group.anyoneCanJoin
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
})
} }

View File

@ -96,22 +96,25 @@ const CACHED_REFERRAL_GROUP_ID_KEY = 'CACHED_REFERRAL_GROUP_KEY'
export function writeReferralInfo( export function writeReferralInfo(
defaultReferrerUsername: string, defaultReferrerUsername: string,
contractId?: string, otherOptions?: {
referralUsername?: string, contractId?: string
overwriteReferralUsername?: string
groupId?: string groupId?: string
}
) { ) {
const local = safeLocalStorage() const local = safeLocalStorage()
const cachedReferralUser = local?.getItem(CACHED_REFERRAL_USERNAME_KEY) const cachedReferralUser = local?.getItem(CACHED_REFERRAL_USERNAME_KEY)
const { contractId, overwriteReferralUsername, groupId } = otherOptions || {}
// Write the first referral username we see. // Write the first referral username we see.
if (!cachedReferralUser) if (!cachedReferralUser)
local?.setItem( local?.setItem(
CACHED_REFERRAL_USERNAME_KEY, CACHED_REFERRAL_USERNAME_KEY,
referralUsername || defaultReferrerUsername overwriteReferralUsername || defaultReferrerUsername
) )
// If an explicit referral query is passed, overwrite the cached referral username. // If an explicit referral query is passed, overwrite the cached referral username.
if (referralUsername) if (overwriteReferralUsername)
local?.setItem(CACHED_REFERRAL_USERNAME_KEY, referralUsername) local?.setItem(CACHED_REFERRAL_USERNAME_KEY, overwriteReferralUsername)
// Always write the most recent explicit group invite query value // Always write the most recent explicit group invite query value
if (groupId) local?.setItem(CACHED_REFERRAL_GROUP_ID_KEY, groupId) if (groupId) local?.setItem(CACHED_REFERRAL_GROUP_ID_KEY, groupId)

View File

@ -6,6 +6,7 @@ import Script from 'next/script'
import { usePreserveScroll } from 'web/hooks/use-preserve-scroll' import { usePreserveScroll } from 'web/hooks/use-preserve-scroll'
import { QueryClient, QueryClientProvider } from 'react-query' import { QueryClient, QueryClientProvider } from 'react-query'
import { AuthProvider } from 'web/components/auth-context' import { AuthProvider } from 'web/components/auth-context'
import Welcome from 'web/components/onboarding/welcome'
function firstLine(msg: string) { function firstLine(msg: string) {
return msg.replace(/\r?\n.*/s, '') 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" content="width=device-width, initial-scale=1, maximum-scale=1"
/> />
</Head> </Head>
<AuthProvider> <AuthProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Welcome {...pageProps} />
<Component {...pageProps} /> <Component {...pageProps} />
</QueryClientProvider> </QueryClientProvider>
</AuthProvider> </AuthProvider>

View 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)
}

View 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)
}

View 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)
}

View 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
View 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.' })
}
}

View File

@ -102,7 +102,7 @@ export default function ContractSearchFirestore(props: {
> >
<option value="newest">Newest</option> <option value="newest">Newest</option>
<option value="oldest">Oldest</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="most-traded">Most traded</option>
<option value="24-hour-vol">24h volume</option> <option value="24-hour-vol">24h volume</option>
<option value="close-date">Closing soon</option> <option value="close-date">Closing soon</option>

View File

@ -19,7 +19,7 @@ import {
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' 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 { Group } from 'common/group'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
@ -122,7 +122,7 @@ export function NewContract(props: {
useEffect(() => { useEffect(() => {
if (groupId && creator) if (groupId && creator)
getGroup(groupId).then((group) => { getGroup(groupId).then((group) => {
if (group && group.memberIds.includes(creator.id)) { if (group && canModifyGroupContracts(group, creator.id)) {
setSelectedGroup(group) setSelectedGroup(group)
setShowGroupSelector(false) setShowGroupSelector(false)
} }
@ -239,10 +239,6 @@ export function NewContract(props: {
selectedGroup: selectedGroup?.id, selectedGroup: selectedGroup?.id,
isFree: false, isFree: false,
}) })
if (result && selectedGroup) {
await setContractGroupLinks(selectedGroup, result.id, creator.id)
}
await router.push(contractPath(result as Contract)) await router.push(contractPath(result as Contract))
} catch (e) { } catch (e) {
console.error('error creating contract', e, (e as any).details) console.error('error creating contract', e, (e as any).details)
@ -477,6 +473,8 @@ export function NewContract(props: {
{isSubmitting ? 'Creating...' : 'Create question'} {isSubmitting ? 'Creating...' : 'Create question'}
</button> </button>
</Row> </Row>
<Spacer h={6} />
</div> </div>
) )
} }

View File

@ -16,7 +16,7 @@ import { Row } from 'web/components/layout/row'
import { UserLink } from 'web/components/user-page' import { UserLink } from 'web/components/user-page'
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col' 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 { listMembers, useGroup, useMembers } from 'web/hooks/use-group'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { scoreCreators, scoreTraders } from 'common/scoring' 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 { Tabs } from 'web/components/layout/tabs'
import { CreateQuestionButton } from 'web/components/create-question-button' import { CreateQuestionButton } from 'web/components/create-question-button'
import React, { useState } from 'react' 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 { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import { getSavedSort } from 'web/hooks/use-sort-and-query-params' 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 { useTipTxns } from 'web/hooks/use-tip-txns'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { useWindowSize } from 'web/hooks/use-window-size'
import { CopyLinkButton } from 'web/components/copy-link-button' import { CopyLinkButton } from 'web/components/copy-link-button'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
import { Comment } from 'common/comment'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) { 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( const bets = await Promise.all(
contracts.map((contract: Contract) => listAllBets(contract.id)) contracts.map((contract: Contract) => listAllBets(contract.id))
) )
const messages = group && (await listAllCommentsOnGroup(group.id))
const creatorScores = scoreCreators(contracts) const creatorScores = scoreCreators(contracts)
const traderScores = scoreTraders(contracts, bets) const traderScores = scoreTraders(contracts, bets)
@ -86,6 +88,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
topTraders, topTraders,
creatorScores, creatorScores,
topCreators, topCreators,
messages,
}, },
revalidate: 60, // regenerate after a minute revalidate: 60, // regenerate after a minute
@ -123,6 +126,7 @@ export default function GroupPage(props: {
topTraders: User[] topTraders: User[]
creatorScores: { [userId: string]: number } creatorScores: { [userId: string]: number }
topCreators: User[] topCreators: User[]
messages: Comment[]
}) { }) {
props = usePropz(props, getStaticPropz) ?? { props = usePropz(props, getStaticPropz) ?? {
group: null, group: null,
@ -132,6 +136,7 @@ export default function GroupPage(props: {
topTraders: [], topTraders: [],
creatorScores: {}, creatorScores: {},
topCreators: [], topCreators: [],
messages: [],
} }
const { const {
creator, creator,
@ -149,19 +154,18 @@ export default function GroupPage(props: {
const group = useGroup(props.group?.id) ?? props.group const group = useGroup(props.group?.id) ?? props.group
const tips = useTipTxns({ groupId: group?.id }) const tips = useTipTxns({ groupId: group?.id })
const messages = useCommentsOnGroup(group?.id) const messages = useCommentsOnGroup(group?.id) ?? props.messages
const user = useUser() const user = useUser()
const privateUser = usePrivateUser(user?.id)
useSaveReferral(user, { useSaveReferral(user, {
defaultReferrer: creator.username, defaultReferrer: creator.username,
groupId: group?.id, groupId: group?.id,
}) })
const { width } = useWindowSize()
const chatDisabled = !group || group.chatDisabled const chatDisabled = !group || group.chatDisabled
const showChatSidebar = !chatDisabled && (width ?? 1280) >= 1280 const showChatBubble = !chatDisabled
const showChatTab = !chatDisabled && !showChatSidebar
if (group === null || !groupSubpages.includes(page) || slugs[2]) { if (group === null || !groupSubpages.includes(page) || slugs[2]) {
return <Custom404 /> return <Custom404 />
@ -195,16 +199,6 @@ export default function GroupPage(props: {
</Col> </Col>
) )
const chatTab = (
<Col className="">
{messages ? (
<GroupChat messages={messages} user={user} group={group} tips={tips} />
) : (
<LoadingIndicator />
)}
</Col>
)
const questionsTab = ( const questionsTab = (
<ContractSearch <ContractSearch
querySortOptions={{ querySortOptions={{
@ -217,15 +211,6 @@ export default function GroupPage(props: {
) )
const tabs = [ const tabs = [
...(!showChatTab
? []
: [
{
title: 'Chat',
content: chatTab,
href: groupPath(group.slug, GROUP_CHAT_SLUG),
},
]),
{ {
title: 'Markets', title: 'Markets',
content: questionsTab, content: questionsTab,
@ -242,20 +227,17 @@ export default function GroupPage(props: {
href: groupPath(group.slug, 'about'), href: groupPath(group.slug, 'about'),
}, },
] ]
const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG) const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG)
return ( return (
<Page <Page>
rightSidebar={showChatSidebar ? chatTab : undefined}
rightSidebarClassName={showChatSidebar ? '!top-0' : ''}
className={showChatSidebar ? '!max-w-7xl !pb-0' : ''}
>
<SEO <SEO
title={group.name} title={group.name}
description={`Created by ${creator.name}. ${group.about}`} description={`Created by ${creator.name}. ${group.about}`}
url={groupPath(group.slug)} url={groupPath(group.slug)}
/> />
<Col className="px-3"> <Col className="relative px-3">
<Row className={'items-center justify-between gap-4'}> <Row className={'items-center justify-between gap-4'}>
<div className={'sm:mb-1'}> <div className={'sm:mb-1'}>
<div <div
@ -282,6 +264,15 @@ export default function GroupPage(props: {
defaultIndex={tabIndex > 0 ? tabIndex : 0} defaultIndex={tabIndex > 0 ? tabIndex : 0}
tabs={tabs} tabs={tabs}
/> />
{showChatBubble && (
<GroupChatInBubble
group={group}
user={user}
privateUser={privateUser}
tips={tips}
messages={messages}
/>
)}
</Page> </Page>
) )
} }

View File

@ -81,11 +81,18 @@ const useContractPage = () => {
if (!username || !contractSlug) setContract(undefined) if (!username || !contractSlug) setContract(undefined)
else { else {
// Show contract if route is to a contract: '/[username]/[contractSlug]'. // 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 const { pushState, replaceState } = window.history
window.history.pushState = function () { window.history.pushState = function () {
@ -101,6 +108,7 @@ const useContractPage = () => {
} }
return () => { return () => {
removeEventListener('popstate', updateContract)
window.history.pushState = pushState window.history.pushState = pushState
window.history.replaceState = replaceState window.history.replaceState = replaceState
} }

View File

@ -1,14 +1,17 @@
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { claimManalink } from 'web/lib/firebase/api' import { claimManalink } from 'web/lib/firebase/api'
import { useManalink } from 'web/lib/firebase/manalinks' import { useManalink } from 'web/lib/firebase/manalinks'
import { ManalinkCard } from 'web/components/manalink-card' import { ManalinkCard } from 'web/components/manalink-card'
import { useUser } from 'web/hooks/use-user' 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 { Row } from 'web/components/layout/row'
import { Button } from 'web/components/button' 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() { export default function ClaimPage() {
const user = useUser() const user = useUser()
@ -18,6 +21,8 @@ export default function ClaimPage() {
const [claiming, setClaiming] = useState(false) const [claiming, setClaiming] = useState(false)
const [error, setError] = useState<string | undefined>(undefined) const [error, setError] = useState<string | undefined>(undefined)
useReferral(user, manalink)
if (!manalink) { if (!manalink) {
return <></> return <></>
} }
@ -33,7 +38,19 @@ export default function ClaimPage() {
<div className="mx-auto max-w-xl px-2"> <div className="mx-auto max-w-xl px-2">
<Row className="items-center justify-between"> <Row className="items-center justify-between">
<Title text={`Claim M$${manalink.amount} mana`} /> <Title text={`Claim M$${manalink.amount} mana`} />
<div className="my-auto"> <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 <Button
onClick={async () => { onClick={async () => {
setClaiming(true) setClaiming(true)
@ -51,9 +68,7 @@ export default function ClaimPage() {
} catch (e) { } catch (e) {
console.log(e) console.log(e)
const message = const message =
e && e instanceof Object e && e instanceof Object ? e.toString() : 'An error occurred.'
? e.toString()
: 'An error occurred.'
setError(message) setError(message)
} }
setClaiming(false) setClaiming(false)
@ -61,18 +76,20 @@ export default function ClaimPage() {
disabled={claiming} disabled={claiming}
size="lg" size="lg"
> >
{user ? 'Claim' : 'Login'} {user ? `Claim M$${manalink.amount}` : 'Login to claim'}
</Button> </Button>
</div>
</Row> </Row>
<ManalinkCard info={info} />
{error && (
<section className="my-5 text-red-500">
<p>Failed to claim manalink.</p>
<p>{error}</p>
</section>
)}
</div> </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 })
}

View File

@ -1,4 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
dayjs.extend(customParseFormat)
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' 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 { CreateLinksButton } from 'web/components/manalinks/create-links-button'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' 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 { ManalinkCardFromView } from 'web/components/manalink-card'
import { Pagination } from 'web/components/pagination' import { Pagination } from 'web/components/pagination'
import { Manalink } from 'common/manalink' import { Manalink } from 'common/manalink'
dayjs.extend(customParseFormat) import { REFERRAL_AMOUNT } from 'common/user'
const LINKS_PER_PAGE = 24 const LINKS_PER_PAGE = 24
export const getServerSideProps = redirectIfLoggedOut('/') export const getServerSideProps = redirectIfLoggedOut('/')
@ -64,8 +67,10 @@ export default function LinkPage() {
)} )}
</Row> </Row>
<p> <p>
You can use manalinks to send mana to other people, even if they You can use manalinks to send mana (M$) to other people, even if they
don&apos;t yet have a Manifold account. don&apos;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> </p>
<Subtitle text="Your Manalinks" /> <Subtitle text="Your Manalinks" />
<ManalinksDisplay <ManalinksDisplay

BIN
web/public/welcome/charity.mp4 Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -18,6 +18,15 @@ module.exports = {
backgroundImage: { backgroundImage: {
'world-trading': "url('/world-trading-background.webp')", '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: { typography: {
quoteless: { quoteless: {
css: { css: {

View File

@ -5144,6 +5144,11 @@ dayjs@1.10.7:
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig== 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: debug@2, debug@2.6.9, debug@^2.6.0, debug@^2.6.9:
version "2.6.9" version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"