Merge branch 'main' into editor-market
This commit is contained in:
commit
8d5246cc09
43
.github/workflows/format.yml
vendored
Normal file
43
.github/workflows/format.yml
vendored
Normal file
|
@ -0,0 +1,43 @@
|
|||
name: Reformat main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
FORCE_COLOR: 3
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
|
||||
# mqp - i generated a personal token to use for these writes -- it's unclear
|
||||
# why, but the default token didn't work, even when i gave it max permissions
|
||||
|
||||
jobs:
|
||||
prettify:
|
||||
name: Auto-prettify
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
token: ${{ secrets.FORMATTER_ACCESS_TOKEN }}
|
||||
- name: Restore cached node_modules
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }}
|
||||
- name: Install missing dependencies
|
||||
run: yarn install --prefer-offline --frozen-lockfile
|
||||
- name: Run Prettier on web client
|
||||
working-directory: web
|
||||
run: yarn format
|
||||
- name: Commit any Prettier changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v4
|
||||
with:
|
||||
commit_message: Auto-prettification
|
||||
branch: ${{ github.head_ref }}
|
|
@ -26,6 +26,7 @@ export type Bet = {
|
|||
isAnte?: boolean
|
||||
isLiquidityProvision?: boolean
|
||||
isRedemption?: boolean
|
||||
challengeSlug?: string
|
||||
} & Partial<LimitProps>
|
||||
|
||||
export type NumericBet = Bet & {
|
||||
|
|
65
common/challenge.ts
Normal file
65
common/challenge.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { IS_PRIVATE_MANIFOLD } from './envs/constants'
|
||||
|
||||
export type Challenge = {
|
||||
// The link to send: https://manifold.markets/challenges/username/market-slug/{slug}
|
||||
// Also functions as the unique id for the link.
|
||||
slug: string
|
||||
|
||||
// The user that created the challenge.
|
||||
creatorId: string
|
||||
creatorUsername: string
|
||||
creatorName: string
|
||||
creatorAvatarUrl?: string
|
||||
|
||||
// Displayed to people claiming the challenge
|
||||
message: string
|
||||
|
||||
// How much to put up
|
||||
creatorAmount: number
|
||||
|
||||
// YES or NO for now
|
||||
creatorOutcome: string
|
||||
|
||||
// Different than the creator
|
||||
acceptorOutcome: string
|
||||
acceptorAmount: number
|
||||
|
||||
// The probability the challenger thinks
|
||||
creatorOutcomeProb: number
|
||||
|
||||
contractId: string
|
||||
contractSlug: string
|
||||
contractQuestion: string
|
||||
contractCreatorUsername: string
|
||||
|
||||
createdTime: number
|
||||
// If null, the link is valid forever
|
||||
expiresTime: number | null
|
||||
|
||||
// How many times the challenge can be used
|
||||
maxUses: number
|
||||
|
||||
// Used for simpler caching
|
||||
acceptedByUserIds: string[]
|
||||
// Successful redemptions of the link
|
||||
acceptances: Acceptance[]
|
||||
|
||||
// TODO: will have to fill this on resolve contract
|
||||
isResolved: boolean
|
||||
resolutionOutcome?: string
|
||||
}
|
||||
|
||||
export type Acceptance = {
|
||||
// User that accepted the challenge
|
||||
userId: string
|
||||
userUsername: string
|
||||
userName: string
|
||||
userAvatarUrl: string
|
||||
|
||||
// The ID of the successful bet that tracks the money moved
|
||||
betId: string
|
||||
|
||||
createdTime: number
|
||||
}
|
||||
|
||||
export const CHALLENGES_ENABLED = !IS_PRIVATE_MANIFOLD
|
|
@ -1,3 +1,5 @@
|
|||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
// Currently, comments are created after the bet, not atomically with the bet.
|
||||
// They're uniquely identified by the pair contractId/betId.
|
||||
export type Comment = {
|
||||
|
@ -9,7 +11,9 @@ export type Comment = {
|
|||
replyToCommentId?: string
|
||||
userId: string
|
||||
|
||||
text: string
|
||||
/** @deprecated - content now stored as JSON in content*/
|
||||
text?: string
|
||||
content: JSONContent
|
||||
createdTime: number
|
||||
|
||||
// Denormalized, for rendering comments
|
||||
|
|
|
@ -37,6 +37,7 @@ export type notification_source_types =
|
|||
| 'group'
|
||||
| 'user'
|
||||
| 'bonus'
|
||||
| 'challenge'
|
||||
|
||||
export type notification_source_update_types =
|
||||
| 'created'
|
||||
|
@ -64,3 +65,4 @@ export type notification_reason_types =
|
|||
| 'tip_received'
|
||||
| 'bet_fill'
|
||||
| 'user_joined_from_your_group_invite'
|
||||
| 'challenge_accepted'
|
||||
|
|
|
@ -40,12 +40,14 @@ export type User = {
|
|||
referredByContractId?: string
|
||||
referredByGroupId?: string
|
||||
lastPingTime?: number
|
||||
shouldShowWelcome?: boolean
|
||||
}
|
||||
|
||||
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
||||
// for sus users, i.e. multiple sign ups for same person
|
||||
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
|
||||
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
|
||||
|
||||
export type PrivateUser = {
|
||||
id: string // same as User.id
|
||||
username: string // denormalized from User
|
||||
|
@ -55,6 +57,7 @@ export type PrivateUser = {
|
|||
unsubscribedFromCommentEmails?: boolean
|
||||
unsubscribedFromAnswerEmails?: boolean
|
||||
unsubscribedFromGenericEmails?: boolean
|
||||
manaBonusEmailSent?: boolean
|
||||
initialDeviceToken?: string
|
||||
initialIpAddress?: string
|
||||
apiKey?: string
|
||||
|
|
|
@ -22,6 +22,7 @@ import { Image } from '@tiptap/extension-image'
|
|||
import { Link } from '@tiptap/extension-link'
|
||||
import { Mention } from '@tiptap/extension-mention'
|
||||
import Iframe from './tiptap-iframe'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
export function parseTags(text: string) {
|
||||
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
||||
|
@ -61,6 +62,15 @@ const checkAgainstQuery = (query: string, corpus: string) =>
|
|||
export const searchInAny = (query: string, ...fields: string[]) =>
|
||||
fields.some((field) => checkAgainstQuery(query, field))
|
||||
|
||||
/** @return user ids of all \@mentions */
|
||||
export function parseMentions(data: JSONContent): string[] {
|
||||
const mentions = data.content?.flatMap(parseMentions) ?? [] //dfs
|
||||
if (data.type === 'mention' && data.attrs) {
|
||||
mentions.push(data.attrs.id as string)
|
||||
}
|
||||
return uniq(mentions)
|
||||
}
|
||||
|
||||
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
|
||||
export const exhibitExts = [
|
||||
Blockquote,
|
||||
|
|
115
docs/docs/api.md
115
docs/docs/api.md
|
@ -46,6 +46,28 @@ Gets a user by their unique ID. Many other API endpoints return this as the `use
|
|||
|
||||
Requires no authorization.
|
||||
|
||||
### GET /v0/me
|
||||
|
||||
Returns the authenticated user.
|
||||
|
||||
### `GET /v0/groups`
|
||||
|
||||
Gets all groups, in no particular order.
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
### `GET /v0/groups/[slug]`
|
||||
|
||||
Gets a group by its slug.
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
### `GET /v0/groups/by-id/[id]`
|
||||
|
||||
Gets a group by its unique ID.
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
### `GET /v0/markets`
|
||||
|
||||
Lists all markets, ordered by creation date descending.
|
||||
|
@ -481,6 +503,20 @@ Parameters:
|
|||
answer. For numeric markets, this is a string representing the target bucket,
|
||||
and an additional `value` parameter is required which is a number representing
|
||||
the target value. (Bet on numeric markets at your own peril.)
|
||||
- `limitProb`: Optional. A number between `0.001` and `0.999` inclusive representing
|
||||
the limit probability for your bet (i.e. 0.1% to 99.9% — multiply by 100 for the
|
||||
probability percentage).
|
||||
The bet will execute immediately in the direction of `outcome`, but not beyond this
|
||||
specified limit. If not all the bet is filled, the bet will remain as an open offer
|
||||
that can later be matched against an opposite direction bet.
|
||||
- For example, if the current market probability is `50%`:
|
||||
- A `M$10` bet on `YES` with `limitProb=0.4` would not be filled until the market
|
||||
probability moves down to `40%` and someone bets `M$15` of `NO` to match your
|
||||
bet odds.
|
||||
- A `M$100` bet on `YES` with `limitProb=0.6` would fill partially or completely
|
||||
depending on current unfilled limit bets and the AMM's liquidity. Any remaining
|
||||
portion of the bet not filled would remain to be matched against in the future.
|
||||
- An unfilled limit order bet can be cancelled using the cancel API.
|
||||
|
||||
Example request:
|
||||
|
||||
|
@ -581,12 +617,12 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
|
|||
|
||||
### `POST /v0/market/[marketId]/sell`
|
||||
|
||||
Sells some quantity of shares in a market on behalf of the authorized user.
|
||||
Sells some quantity of shares in a binary market on behalf of the authorized user.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `outcome`: Required. One of `YES`, `NO`, or a `number` indicating the numeric
|
||||
bucket ID, depending on the market type.
|
||||
- `outcome`: Optional. One of `YES`, or `NO`. If you leave it off, and you only
|
||||
own one kind of shares, you will sell that kind of shares.
|
||||
- `shares`: Optional. The amount of shares to sell of the outcome given
|
||||
above. If not provided, all the shares you own will be sold.
|
||||
|
||||
|
@ -617,7 +653,7 @@ Requires no authorization.
|
|||
|
||||
- Example request
|
||||
```
|
||||
https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-california-abolish-daylight-sa
|
||||
https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-i-be-able-to-place-a-limit-ord
|
||||
```
|
||||
- Response type: A `Bet[]`.
|
||||
|
||||
|
@ -625,31 +661,60 @@ Requires no authorization.
|
|||
|
||||
```json
|
||||
[
|
||||
// Limit bet, partially filled.
|
||||
{
|
||||
"probAfter": 0.44418877319153904,
|
||||
"shares": -645.8346334931828,
|
||||
"isFilled": false,
|
||||
"amount": 15.596681605353808,
|
||||
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
|
||||
"contractId": "Tz5dA01GkK5QKiQfZeDL",
|
||||
"probBefore": 0.5730753474948571,
|
||||
"isCancelled": false,
|
||||
"outcome": "YES",
|
||||
"contractId": "tgB1XmvFXZNhjr3xMNLp",
|
||||
"sale": {
|
||||
"betId": "RcOtarI3d1DUUTjiE0rx",
|
||||
"amount": 474.9999999999998
|
||||
},
|
||||
"createdTime": 1644602886293,
|
||||
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2",
|
||||
"probBefore": 0.7229189477449224,
|
||||
"id": "x9eNmCaqQeXW8AgJ8Zmp",
|
||||
"amount": -499.9999999999998
|
||||
"fees": { "creatorFee": 0, "liquidityFee": 0, "platformFee": 0 },
|
||||
"shares": 31.193363210707616,
|
||||
"limitProb": 0.5,
|
||||
"id": "yXB8lVbs86TKkhWA1FVi",
|
||||
"loanAmount": 0,
|
||||
"orderAmount": 100,
|
||||
"probAfter": 0.5730753474948571,
|
||||
"createdTime": 1659482775970,
|
||||
"fills": [
|
||||
{
|
||||
"timestamp": 1659483249648,
|
||||
"matchedBetId": "MfrMd5HTiGASDXzqibr7",
|
||||
"amount": 15.596681605353808,
|
||||
"shares": 31.193363210707616
|
||||
}
|
||||
]
|
||||
},
|
||||
// Normal bet (no limitProb specified).
|
||||
{
|
||||
"probAfter": 0.9901970375647697,
|
||||
"contractId": "zdeaYVAfHlo9jKzWh57J",
|
||||
"outcome": "YES",
|
||||
"amount": 1,
|
||||
"id": "8PqxKYwXCcLYoXy2m2Nm",
|
||||
"shares": 1.0049875638533763,
|
||||
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2",
|
||||
"probBefore": 0.9900000000000001,
|
||||
"createdTime": 1644705818872
|
||||
"shares": 17.350459904608414,
|
||||
"probBefore": 0.5304358279113885,
|
||||
"isFilled": true,
|
||||
"probAfter": 0.5730753474948571,
|
||||
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
|
||||
"amount": 10,
|
||||
"contractId": "Tz5dA01GkK5QKiQfZeDL",
|
||||
"id": "1LPJHNz5oAX4K6YtJlP1",
|
||||
"fees": {
|
||||
"platformFee": 0,
|
||||
"liquidityFee": 0,
|
||||
"creatorFee": 0.4251333951457593
|
||||
},
|
||||
"isCancelled": false,
|
||||
"loanAmount": 0,
|
||||
"orderAmount": 10,
|
||||
"fills": [
|
||||
{
|
||||
"amount": 10,
|
||||
"matchedBetId": null,
|
||||
"shares": 17.350459904608414,
|
||||
"timestamp": 1659482757271
|
||||
}
|
||||
],
|
||||
"createdTime": 1659482757271,
|
||||
"outcome": "YES"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
|
|
@ -10,6 +10,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
|||
|
||||
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government
|
||||
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
|
||||
- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$.
|
||||
|
||||
## API / Dev
|
||||
|
||||
|
@ -21,3 +22,4 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
|||
## Bots
|
||||
|
||||
- [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon
|
||||
- [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets
|
||||
|
|
|
@ -22,7 +22,7 @@ service cloud.firestore {
|
|||
allow read;
|
||||
allow update: if resource.data.id == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime']);
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']);
|
||||
// User referral rules
|
||||
allow update: if resource.data.id == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
|
@ -39,6 +39,17 @@ service cloud.firestore {
|
|||
allow read;
|
||||
}
|
||||
|
||||
match /{somePath=**}/challenges/{challengeId}{
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /contracts/{contractId}/challenges/{challengeId}{
|
||||
allow read;
|
||||
allow create: if request.auth.uid == request.resource.data.creatorId;
|
||||
// allow update if there have been no claims yet and if the challenge is still open
|
||||
allow update: if request.auth.uid == resource.data.creatorId;
|
||||
}
|
||||
|
||||
match /users/{userId}/follows/{followUserId} {
|
||||
allow read;
|
||||
allow write: if request.auth.uid == userId;
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
"dayjs": "1.11.4",
|
||||
"cors": "2.8.5",
|
||||
"express": "4.18.1",
|
||||
"firebase-admin": "10.0.0",
|
||||
|
|
167
functions/src/accept-challenge.ts
Normal file
167
functions/src/accept-challenge.ts
Normal file
|
@ -0,0 +1,167 @@
|
|||
import { z } from 'zod'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { log } from './utils'
|
||||
import { Contract, CPMMBinaryContract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { Acceptance, Challenge } from '../../common/challenge'
|
||||
import { CandidateBet } from '../../common/new-bet'
|
||||
import { createChallengeAcceptedNotification } from './create-notification'
|
||||
import { noFees } from '../../common/fees'
|
||||
import { formatMoney, formatPercent } from '../../common/util/format'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
challengeSlug: z.string(),
|
||||
outcomeType: z.literal('BINARY'),
|
||||
closeTime: z.number().gte(Date.now()),
|
||||
})
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const acceptchallenge = newEndpoint({}, async (req, auth) => {
|
||||
const { challengeSlug, contractId } = validate(bodySchema, req.body)
|
||||
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const challengeDoc = firestore.doc(
|
||||
`contracts/${contractId}/challenges/${challengeSlug}`
|
||||
)
|
||||
const [contractSnap, userSnap, challengeSnap] = await trans.getAll(
|
||||
contractDoc,
|
||||
userDoc,
|
||||
challengeDoc
|
||||
)
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
if (!challengeSnap.exists) throw new APIError(400, 'Challenge not found.')
|
||||
|
||||
const anyContract = contractSnap.data() as Contract
|
||||
const user = userSnap.data() as User
|
||||
const challenge = challengeSnap.data() as Challenge
|
||||
|
||||
if (challenge.acceptances.length > 0)
|
||||
throw new APIError(400, 'Challenge already accepted.')
|
||||
|
||||
const creatorDoc = firestore.doc(`users/${challenge.creatorId}`)
|
||||
const creatorSnap = await trans.get(creatorDoc)
|
||||
if (!creatorSnap.exists) throw new APIError(400, 'Creator not found.')
|
||||
const creator = creatorSnap.data() as User
|
||||
|
||||
const {
|
||||
creatorAmount,
|
||||
acceptorOutcome,
|
||||
creatorOutcome,
|
||||
creatorOutcomeProb,
|
||||
acceptorAmount,
|
||||
} = challenge
|
||||
|
||||
if (user.balance < acceptorAmount)
|
||||
throw new APIError(400, 'Insufficient balance.')
|
||||
|
||||
if (creator.balance < creatorAmount)
|
||||
throw new APIError(400, 'Creator has insufficient balance.')
|
||||
|
||||
const contract = anyContract as CPMMBinaryContract
|
||||
const shares = (1 / creatorOutcomeProb) * creatorAmount
|
||||
const createdTime = Date.now()
|
||||
const probOfYes =
|
||||
creatorOutcome === 'YES' ? creatorOutcomeProb : 1 - creatorOutcomeProb
|
||||
|
||||
log(
|
||||
'Creating challenge bet for',
|
||||
user.username,
|
||||
shares,
|
||||
acceptorOutcome,
|
||||
'shares',
|
||||
'at',
|
||||
formatPercent(creatorOutcomeProb),
|
||||
'for',
|
||||
formatMoney(acceptorAmount)
|
||||
)
|
||||
|
||||
const yourNewBet: CandidateBet = removeUndefinedProps({
|
||||
orderAmount: acceptorAmount,
|
||||
amount: acceptorAmount,
|
||||
shares,
|
||||
isCancelled: false,
|
||||
contractId: contract.id,
|
||||
outcome: acceptorOutcome,
|
||||
probBefore: probOfYes,
|
||||
probAfter: probOfYes,
|
||||
loanAmount: 0,
|
||||
createdTime,
|
||||
fees: noFees,
|
||||
challengeSlug: challenge.slug,
|
||||
})
|
||||
|
||||
const yourNewBetDoc = contractDoc.collection('bets').doc()
|
||||
trans.create(yourNewBetDoc, {
|
||||
id: yourNewBetDoc.id,
|
||||
userId: user.id,
|
||||
...yourNewBet,
|
||||
})
|
||||
|
||||
trans.update(userDoc, { balance: FieldValue.increment(-yourNewBet.amount) })
|
||||
|
||||
const creatorNewBet: CandidateBet = removeUndefinedProps({
|
||||
orderAmount: creatorAmount,
|
||||
amount: creatorAmount,
|
||||
shares,
|
||||
isCancelled: false,
|
||||
contractId: contract.id,
|
||||
outcome: creatorOutcome,
|
||||
probBefore: probOfYes,
|
||||
probAfter: probOfYes,
|
||||
loanAmount: 0,
|
||||
createdTime,
|
||||
fees: noFees,
|
||||
challengeSlug: challenge.slug,
|
||||
})
|
||||
const creatorBetDoc = contractDoc.collection('bets').doc()
|
||||
trans.create(creatorBetDoc, {
|
||||
id: creatorBetDoc.id,
|
||||
userId: creator.id,
|
||||
...creatorNewBet,
|
||||
})
|
||||
|
||||
trans.update(creatorDoc, {
|
||||
balance: FieldValue.increment(-creatorNewBet.amount),
|
||||
})
|
||||
|
||||
const volume = contract.volume + yourNewBet.amount + creatorNewBet.amount
|
||||
trans.update(contractDoc, { volume })
|
||||
|
||||
trans.update(
|
||||
challengeDoc,
|
||||
removeUndefinedProps({
|
||||
acceptedByUserIds: [user.id],
|
||||
acceptances: [
|
||||
{
|
||||
userId: user.id,
|
||||
betId: yourNewBetDoc.id,
|
||||
createdTime,
|
||||
amount: acceptorAmount,
|
||||
userUsername: user.username,
|
||||
userName: user.name,
|
||||
userAvatarUrl: user.avatarUrl,
|
||||
} as Acceptance,
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
await createChallengeAcceptedNotification(
|
||||
user,
|
||||
creator,
|
||||
challenge,
|
||||
acceptorAmount,
|
||||
contract
|
||||
)
|
||||
log('Done, sent notification.')
|
||||
return yourNewBetDoc
|
||||
})
|
||||
|
||||
return { betId: result.id }
|
||||
})
|
|
@ -78,6 +78,19 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
|||
}
|
||||
}
|
||||
|
||||
export const writeResponseError = (e: unknown, res: Response) => {
|
||||
if (e instanceof APIError) {
|
||||
const output: { [k: string]: unknown } = { message: e.message }
|
||||
if (e.details != null) {
|
||||
output.details = e.details
|
||||
}
|
||||
res.status(e.code).json(output)
|
||||
} else {
|
||||
error(e)
|
||||
res.status(500).json({ message: 'An unknown error occurred.' })
|
||||
}
|
||||
}
|
||||
|
||||
export const zTimestamp = () => {
|
||||
return z.preprocess((arg) => {
|
||||
return typeof arg == 'number' ? new Date(arg) : undefined
|
||||
|
@ -131,16 +144,7 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
|
|||
const authedUser = await lookupUser(await parseCredentials(req))
|
||||
res.status(200).json(await fn(req, authedUser))
|
||||
} catch (e) {
|
||||
if (e instanceof APIError) {
|
||||
const output: { [k: string]: unknown } = { message: e.message }
|
||||
if (e.details != null) {
|
||||
output.details = e.details
|
||||
}
|
||||
res.status(e.code).json(output)
|
||||
} else {
|
||||
error(e)
|
||||
res.status(500).json({ message: 'An unknown error occurred.' })
|
||||
}
|
||||
writeResponseError(e, res)
|
||||
}
|
||||
},
|
||||
} as EndpointDefinition
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
import { slugify } from '../../common/util/slugify'
|
||||
import { randomString } from '../../common/util/random'
|
||||
|
||||
import { chargeUser } from './utils'
|
||||
import { chargeUser, getContract } from './utils'
|
||||
import { APIError, newEndpoint, validate, zTimestamp } from './api'
|
||||
|
||||
import {
|
||||
|
@ -28,11 +28,11 @@ import { Answer, getNoneAnswer } from '../../common/answer'
|
|||
import { getNewContract } from '../../common/new-contract'
|
||||
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
|
||||
import { User } from '../../common/user'
|
||||
import { Group, MAX_ID_LENGTH } from '../../common/group'
|
||||
import { Group, GroupLink, MAX_ID_LENGTH } from '../../common/group'
|
||||
import { getPseudoProbability } from '../../common/pseudo-numeric'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { zip } from 'lodash'
|
||||
import { Bet } from 'common/bet'
|
||||
import { uniq, zip } from 'lodash'
|
||||
import { Bet } from '../../common/bet'
|
||||
|
||||
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
||||
z.intersection(
|
||||
|
@ -136,27 +136,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
const slug = await getSlug(question)
|
||||
const contractRef = firestore.collection('contracts').doc()
|
||||
|
||||
let group = null
|
||||
if (groupId) {
|
||||
const groupDocRef = firestore.collection('groups').doc(groupId)
|
||||
const groupDoc = await groupDocRef.get()
|
||||
if (!groupDoc.exists) {
|
||||
throw new APIError(400, 'No group exists with the given group ID.')
|
||||
}
|
||||
|
||||
group = groupDoc.data() as Group
|
||||
if (!group.memberIds.includes(user.id)) {
|
||||
throw new APIError(
|
||||
400,
|
||||
'User must be a member of the group to add markets to it.'
|
||||
)
|
||||
}
|
||||
if (!group.contractIds.includes(contractRef.id))
|
||||
await groupDocRef.update({
|
||||
contractIds: [...group.contractIds, contractRef.id],
|
||||
})
|
||||
}
|
||||
|
||||
console.log(
|
||||
'creating contract for',
|
||||
user.username,
|
||||
|
@ -188,6 +167,33 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
await contractRef.create(contract)
|
||||
|
||||
let group = null
|
||||
if (groupId) {
|
||||
const groupDocRef = firestore.collection('groups').doc(groupId)
|
||||
const groupDoc = await groupDocRef.get()
|
||||
if (!groupDoc.exists) {
|
||||
throw new APIError(400, 'No group exists with the given group ID.')
|
||||
}
|
||||
|
||||
group = groupDoc.data() as Group
|
||||
if (
|
||||
!group.memberIds.includes(user.id) &&
|
||||
!group.anyoneCanJoin &&
|
||||
group.creatorId !== user.id
|
||||
) {
|
||||
throw new APIError(
|
||||
400,
|
||||
'User must be a member/creator of the group or group must be open to add markets to it.'
|
||||
)
|
||||
}
|
||||
if (!group.contractIds.includes(contractRef.id)) {
|
||||
await createGroupLinks(group, [contractRef.id], auth.uid)
|
||||
await groupDocRef.update({
|
||||
contractIds: uniq([...group.contractIds, contractRef.id]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const providerId = user.id
|
||||
|
||||
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
||||
|
@ -284,3 +290,38 @@ export async function getContractFromSlug(slug: string) {
|
|||
|
||||
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
|
||||
}
|
||||
|
||||
async function createGroupLinks(
|
||||
group: Group,
|
||||
contractIds: string[],
|
||||
userId: string
|
||||
) {
|
||||
for (const contractId of contractIds) {
|
||||
const contract = await getContract(contractId)
|
||||
if (!contract?.groupSlugs?.includes(group.slug)) {
|
||||
await firestore
|
||||
.collection('contracts')
|
||||
.doc(contractId)
|
||||
.update({
|
||||
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]),
|
||||
})
|
||||
}
|
||||
if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) {
|
||||
await firestore
|
||||
.collection('contracts')
|
||||
.doc(contractId)
|
||||
.update({
|
||||
groupLinks: [
|
||||
{
|
||||
groupId: group.id,
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
userId,
|
||||
createdTime: Date.now(),
|
||||
} as GroupLink,
|
||||
...(contract?.groupLinks ?? []),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from '../../common/notification'
|
||||
import { User } from '../../common/user'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { getUserByUsername, getValues } from './utils'
|
||||
import { getValues } from './utils'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { uniq } from 'lodash'
|
||||
import { Bet, LimitBet } from '../../common/bet'
|
||||
|
@ -16,6 +16,8 @@ import { getContractBetMetrics } from '../../common/calculate'
|
|||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { TipTxn } from '../../common/txn'
|
||||
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
||||
import { Challenge } from '../../common/challenge'
|
||||
import { richTextToString } from '../../common/util/parse'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
type user_to_reason_texts = {
|
||||
|
@ -32,7 +34,7 @@ export const createNotification = async (
|
|||
miscData?: {
|
||||
contract?: Contract
|
||||
relatedSourceType?: notification_source_types
|
||||
relatedUserId?: string
|
||||
recipients?: string[]
|
||||
slug?: string
|
||||
title?: string
|
||||
}
|
||||
|
@ -40,7 +42,7 @@ export const createNotification = async (
|
|||
const {
|
||||
contract: sourceContract,
|
||||
relatedSourceType,
|
||||
relatedUserId,
|
||||
recipients,
|
||||
slug,
|
||||
title,
|
||||
} = miscData ?? {}
|
||||
|
@ -127,7 +129,7 @@ export const createNotification = async (
|
|||
})
|
||||
}
|
||||
|
||||
const notifyRepliedUsers = async (
|
||||
const notifyRepliedUser = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string,
|
||||
relatedSourceType: notification_source_types
|
||||
|
@ -144,7 +146,7 @@ export const createNotification = async (
|
|||
}
|
||||
}
|
||||
|
||||
const notifyFollowedUser = async (
|
||||
const notifyFollowedUser = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
followedUserId: string
|
||||
) => {
|
||||
|
@ -154,21 +156,13 @@ export const createNotification = async (
|
|||
}
|
||||
}
|
||||
|
||||
const notifyTaggedUsers = async (
|
||||
const notifyTaggedUsers = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
sourceText: string
|
||||
userIds: (string | undefined)[]
|
||||
) => {
|
||||
const taggedUsers = sourceText.match(/@\w+/g)
|
||||
if (!taggedUsers) return
|
||||
// await all get tagged users:
|
||||
const users = await Promise.all(
|
||||
taggedUsers.map(async (username) => {
|
||||
return await getUserByUsername(username.slice(1))
|
||||
})
|
||||
)
|
||||
users.forEach((taggedUser) => {
|
||||
if (taggedUser && shouldGetNotification(taggedUser.id, userToReasonTexts))
|
||||
userToReasonTexts[taggedUser.id] = {
|
||||
userIds.forEach((id) => {
|
||||
if (id && shouldGetNotification(id, userToReasonTexts))
|
||||
userToReasonTexts[id] = {
|
||||
reason: 'tagged_user',
|
||||
}
|
||||
})
|
||||
|
@ -253,7 +247,7 @@ export const createNotification = async (
|
|||
})
|
||||
}
|
||||
|
||||
const notifyUserAddedToGroup = async (
|
||||
const notifyUserAddedToGroup = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string
|
||||
) => {
|
||||
|
@ -275,11 +269,14 @@ export const createNotification = async (
|
|||
const getUsersToNotify = async () => {
|
||||
const userToReasonTexts: user_to_reason_texts = {}
|
||||
// The following functions modify the userToReasonTexts object in place.
|
||||
if (sourceType === 'follow' && relatedUserId) {
|
||||
await notifyFollowedUser(userToReasonTexts, relatedUserId)
|
||||
} else if (sourceType === 'group' && relatedUserId) {
|
||||
if (sourceUpdateType === 'created')
|
||||
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
|
||||
if (sourceType === 'follow' && recipients?.[0]) {
|
||||
notifyFollowedUser(userToReasonTexts, recipients[0])
|
||||
} else if (
|
||||
sourceType === 'group' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
recipients
|
||||
) {
|
||||
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
|
||||
}
|
||||
|
||||
// The following functions need sourceContract to be defined.
|
||||
|
@ -292,13 +289,9 @@ export const createNotification = async (
|
|||
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
||||
) {
|
||||
if (sourceType === 'comment') {
|
||||
if (relatedUserId && relatedSourceType)
|
||||
await notifyRepliedUsers(
|
||||
userToReasonTexts,
|
||||
relatedUserId,
|
||||
relatedSourceType
|
||||
)
|
||||
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText)
|
||||
if (recipients?.[0] && relatedSourceType)
|
||||
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
|
||||
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||
}
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
||||
|
@ -307,6 +300,7 @@ export const createNotification = async (
|
|||
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
||||
await notifyUsersFollowers(userToReasonTexts)
|
||||
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
||||
force: true,
|
||||
|
@ -422,7 +416,7 @@ export const createGroupCommentNotification = async (
|
|||
sourceUserName: fromUser.name,
|
||||
sourceUserUsername: fromUser.username,
|
||||
sourceUserAvatarUrl: fromUser.avatarUrl,
|
||||
sourceText: comment.text,
|
||||
sourceText: richTextToString(comment.content),
|
||||
sourceSlug,
|
||||
sourceTitle: `${group.name}`,
|
||||
isSeenOnHref: sourceSlug,
|
||||
|
@ -478,3 +472,35 @@ export const createReferralNotification = async (
|
|||
}
|
||||
|
||||
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
|
||||
|
||||
export const createChallengeAcceptedNotification = async (
|
||||
challenger: User,
|
||||
challengeCreator: User,
|
||||
challenge: Challenge,
|
||||
acceptedAmount: number,
|
||||
contract: Contract
|
||||
) => {
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${challengeCreator.id}/notifications`)
|
||||
.doc()
|
||||
const notification: Notification = {
|
||||
id: notificationRef.id,
|
||||
userId: challengeCreator.id,
|
||||
reason: 'challenge_accepted',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: challenge.slug,
|
||||
sourceType: 'challenge',
|
||||
sourceUpdateType: 'updated',
|
||||
sourceUserName: challenger.name,
|
||||
sourceUserUsername: challenger.username,
|
||||
sourceUserAvatarUrl: challenger.avatarUrl,
|
||||
sourceText: acceptedAmount.toString(),
|
||||
sourceContractCreatorUsername: contract.creatorUsername,
|
||||
sourceContractTitle: contract.question,
|
||||
sourceContractSlug: contract.slug,
|
||||
sourceContractId: contract.id,
|
||||
sourceSlug: `/challenges/${challengeCreator.username}/${challenge.contractSlug}/${challenge.slug}`,
|
||||
}
|
||||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
import {
|
||||
MANIFOLD_AVATAR_URL,
|
||||
MANIFOLD_USERNAME,
|
||||
|
@ -24,7 +26,6 @@ import {
|
|||
import { track } from './analytics'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
|
||||
import { uniq } from 'lodash'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
|
@ -77,6 +78,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
|||
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
||||
followerCountCached: 0,
|
||||
followedCategories: DEFAULT_CATEGORIES,
|
||||
shouldShowWelcome: true,
|
||||
}
|
||||
|
||||
await firestore.collection('users').doc(auth.uid).create(user)
|
||||
|
@ -92,8 +94,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
|||
|
||||
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
||||
|
||||
await sendWelcomeEmail(user, privateUser)
|
||||
await addUserToDefaultGroups(user)
|
||||
await sendWelcomeEmail(user, privateUser)
|
||||
await track(auth.uid, 'create user', { username }, { ip: req.ip })
|
||||
|
||||
return user
|
||||
|
|
File diff suppressed because one or more lines are too long
738
functions/src/email-templates/creating-market.html
Normal file
738
functions/src/email-templates/creating-market.html
Normal file
|
@ -0,0 +1,738 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office"
|
||||
>
|
||||
<head>
|
||||
<title>(no subject)</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Readex+Pro"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Readex+Pro"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width: 480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
[owa] .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width: 480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||
<div style="background-color: #f4f4f4">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div
|
||||
style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 600px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="background: #ffffff; background-color: #ffffff; width: 100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0px 0px 0px 0px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
padding-top: 0px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="vertical-align: top"
|
||||
width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 0px 25px 0px 25px;
|
||||
padding-top: 0px;
|
||||
padding-right: 25px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 25px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0px;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 550px">
|
||||
<a
|
||||
href="https://manifold.markets/home"
|
||||
target="_blank"
|
||||
><img
|
||||
alt=""
|
||||
height="auto"
|
||||
src="https://03jlj.mjt.lu/img/03jlj/b/96u/omk8.gif"
|
||||
style="
|
||||
border: none;
|
||||
display: block;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
"
|
||||
width="550"
|
||||
/></a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div
|
||||
style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 600px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="background: #ffffff; background-color: #ffffff; width: 100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 0px 0px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
padding-top: 20px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="vertical-align: top"
|
||||
width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="left"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 0px 25px 20px 25px;
|
||||
padding-top: 0px;
|
||||
padding-right: 25px;
|
||||
padding-bottom: 20px;
|
||||
padding-left: 25px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 17px;
|
||||
letter-spacing: normal;
|
||||
line-height: 1;
|
||||
text-align: left;
|
||||
color: #000000;
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="
|
||||
line-height: 23px;
|
||||
margin: 10px 0;
|
||||
margin-top: 10px;
|
||||
"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>On Manifold Markets, several important factors
|
||||
go into making a good question. These lead to
|
||||
more people betting on them and allowing a more
|
||||
accurate prediction to be formed!</span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>Manifold also gives its creators 10 Mana for
|
||||
each unique trader that bets on your
|
||||
market!</span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #292fd7;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 20px;
|
||||
"
|
||||
><b>What makes a good question?</b></span
|
||||
>
|
||||
</p>
|
||||
<ul>
|
||||
<li style="line-height: 23px">
|
||||
<span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><b>Clear resolution criteria. </b>This is
|
||||
needed so users know how you are going to
|
||||
decide on what the correct answer is.</span
|
||||
>
|
||||
</li>
|
||||
<li style="line-height: 23px">
|
||||
<span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><b>Clear resolution date</b>. This is
|
||||
sometimes slightly different from the closing
|
||||
date. We recommend leaving the market open up
|
||||
until you resolve it, but if it is different
|
||||
make sure you say what day you intend to
|
||||
resolve it in the description!</span
|
||||
>
|
||||
</li>
|
||||
<li style="line-height: 23px">
|
||||
<span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><b>Detailed description. </b>Use the rich
|
||||
text editor to create an easy to read
|
||||
description. Include any context or background
|
||||
information that could be useful to people who
|
||||
are interested in learning more that are
|
||||
uneducated on the subject.</span
|
||||
>
|
||||
</li>
|
||||
<li style="line-height: 23px">
|
||||
<span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><b>Add it to a group. </b>Groups are the
|
||||
primary way users filter for relevant markets.
|
||||
Also, consider making your own groups and
|
||||
inviting friends/interested communities to
|
||||
them from other sites!</span
|
||||
>
|
||||
</li>
|
||||
<li style="line-height: 23px">
|
||||
<span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><b>Bonus: </b>Add a comment on your
|
||||
prediction and explain (with links and
|
||||
sources) supporting it.</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #292fd7;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 20px;
|
||||
"
|
||||
><b
|
||||
>Examples of markets you should
|
||||
emulate! </b
|
||||
></span
|
||||
>
|
||||
</p>
|
||||
<ul>
|
||||
<li style="line-height: 23px">
|
||||
<a
|
||||
class="link-build-content"
|
||||
style="color: inherit; text-decoration: none"
|
||||
target="_blank"
|
||||
href="https://manifold.markets/DavidChee/will-our-upcoming-twitch-bot-be-a-s"
|
||||
><span
|
||||
style="
|
||||
color: #55575d;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><u>This complex market</u></span
|
||||
></a
|
||||
><span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>
|
||||
about the project I am working on.</span
|
||||
>
|
||||
</li>
|
||||
<li style="line-height: 23px">
|
||||
<a
|
||||
class="link-build-content"
|
||||
style="color: inherit; text-decoration: none"
|
||||
target="_blank"
|
||||
href="https://manifold.markets/SneakySly/will-manifold-reach-1000-weekly-act"
|
||||
><span
|
||||
style="
|
||||
color: #55575d;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><u>This simple market</u></span
|
||||
></a
|
||||
><span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>
|
||||
about Manifold's weekly active
|
||||
users.</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #000000;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>Why not </span>
|
||||
|
||||
|
||||
|
||||
<a
|
||||
class="link-build-content"
|
||||
style="color: inherit; text-decoration: none"
|
||||
target="_blank"
|
||||
href="https://manifold.markets/create"
|
||||
><span
|
||||
style="
|
||||
color: #55575d;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><u>create a market</u></span
|
||||
></a
|
||||
><span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>
|
||||
while it is still fresh on your mind?
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #000000;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>Thanks for reading!</span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="
|
||||
line-height: 23px;
|
||||
margin: 10px 0;
|
||||
margin-bottom: 10px;
|
||||
"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #000000;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>David from Manifold</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="width: 100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0 0 20px 0;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="width: 100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 20px 0px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0">
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
font-family: Ubuntu, Helvetica, Arial,
|
||||
sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
color: #000000;
|
||||
"
|
||||
>
|
||||
<p style="margin: 10px 0">
|
||||
This e-mail has been sent to {{name}},
|
||||
<a
|
||||
href="{{unsubscribeLink}}"
|
||||
style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
"
|
||||
target="_blank"
|
||||
>click here to unsubscribe</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
"
|
||||
></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -17,6 +17,7 @@ import { formatNumericProbability } from '../../common/pseudo-numeric'
|
|||
import { sendTemplateEmail } from './send-email'
|
||||
import { getPrivateUser, getUser } from './utils'
|
||||
import { getFunctionUrl } from '../../common/api'
|
||||
import { richTextToString } from '../../common/util/parse'
|
||||
|
||||
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
|
||||
|
||||
|
@ -165,7 +166,6 @@ export const sendWelcomeEmail = async (
|
|||
)
|
||||
}
|
||||
|
||||
// TODO: use manalinks to give out M$500
|
||||
export const sendOneWeekBonusEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser
|
||||
|
@ -185,12 +185,12 @@ export const sendOneWeekBonusEmail = async (
|
|||
|
||||
await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Manifold one week anniversary gift',
|
||||
'Manifold Markets one week anniversary gift',
|
||||
'one-week',
|
||||
{
|
||||
name: firstName,
|
||||
unsubscribeLink,
|
||||
manalink: '', // TODO
|
||||
manalink: 'https://manifold.markets/link/lj4JbBvE',
|
||||
},
|
||||
{
|
||||
from: 'David from Manifold <david@manifold.markets>',
|
||||
|
@ -292,7 +292,8 @@ export const sendNewCommentEmail = async (
|
|||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||
|
||||
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
|
||||
const { text } = comment
|
||||
const { content } = comment
|
||||
const text = richTextToString(content)
|
||||
|
||||
let betDescription = ''
|
||||
if (bet) {
|
||||
|
|
18
functions/src/get-current-user.ts
Normal file
18
functions/src/get-current-user.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { User } from 'common/user'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { newEndpoint, APIError } from './api'
|
||||
|
||||
export const getcurrentuser = newEndpoint(
|
||||
{ method: 'GET' },
|
||||
async (_req, auth) => {
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const [userSnap] = await firestore.getAll(userDoc)
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
|
||||
const user = userSnap.data() as User
|
||||
|
||||
return user
|
||||
}
|
||||
)
|
||||
|
||||
const firestore = admin.firestore()
|
33
functions/src/get-custom-token.ts
Normal file
33
functions/src/get-custom-token.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import {
|
||||
APIError,
|
||||
EndpointDefinition,
|
||||
lookupUser,
|
||||
parseCredentials,
|
||||
writeResponseError,
|
||||
} from './api'
|
||||
|
||||
const opts = {
|
||||
method: 'GET',
|
||||
minInstances: 1,
|
||||
concurrency: 100,
|
||||
memory: '2GiB',
|
||||
cpu: 1,
|
||||
} as const
|
||||
|
||||
export const getcustomtoken: EndpointDefinition = {
|
||||
opts,
|
||||
handler: async (req, res) => {
|
||||
try {
|
||||
const credentials = await parseCredentials(req)
|
||||
if (credentials.kind != 'jwt') {
|
||||
throw new APIError(403, 'API keys cannot mint custom tokens.')
|
||||
}
|
||||
const user = await lookupUser(credentials)
|
||||
const token = await admin.auth().createCustomToken(user.uid)
|
||||
res.status(200).json({ token: token })
|
||||
} catch (e) {
|
||||
writeResponseError(e, res)
|
||||
}
|
||||
},
|
||||
}
|
|
@ -27,6 +27,25 @@ export * from './on-delete-group'
|
|||
export * from './score-contracts'
|
||||
|
||||
// v2
|
||||
export * from './health'
|
||||
export * from './transact'
|
||||
export * from './change-user-info'
|
||||
export * from './create-user'
|
||||
export * from './create-answer'
|
||||
export * from './place-bet'
|
||||
export * from './cancel-bet'
|
||||
export * from './sell-bet'
|
||||
export * from './sell-shares'
|
||||
export * from './claim-manalink'
|
||||
export * from './create-contract'
|
||||
export * from './add-liquidity'
|
||||
export * from './withdraw-liquidity'
|
||||
export * from './create-group'
|
||||
export * from './resolve-market'
|
||||
export * from './unsubscribe'
|
||||
export * from './stripe'
|
||||
export * from './mana-bonus-email'
|
||||
|
||||
import { health } from './health'
|
||||
import { transact } from './transact'
|
||||
import { changeuserinfo } from './change-user-info'
|
||||
|
@ -44,6 +63,9 @@ import { creategroup } from './create-group'
|
|||
import { resolvemarket } from './resolve-market'
|
||||
import { unsubscribe } from './unsubscribe'
|
||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||
import { getcurrentuser } from './get-current-user'
|
||||
import { acceptchallenge } from './accept-challenge'
|
||||
import { getcustomtoken } from './get-custom-token'
|
||||
|
||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||
return onRequest(opts, handler as any)
|
||||
|
@ -66,6 +88,9 @@ const resolveMarketFunction = toCloudFunction(resolvemarket)
|
|||
const unsubscribeFunction = toCloudFunction(unsubscribe)
|
||||
const stripeWebhookFunction = toCloudFunction(stripewebhook)
|
||||
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
||||
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||
const getCustomTokenFunction = toCloudFunction(getcustomtoken)
|
||||
|
||||
export {
|
||||
healthFunction as health,
|
||||
|
@ -86,4 +111,7 @@ export {
|
|||
unsubscribeFunction as unsubscribe,
|
||||
stripeWebhookFunction as stripewebhook,
|
||||
createCheckoutSessionFunction as createcheckoutsession,
|
||||
getCurrentUserFunction as getcurrentuser,
|
||||
acceptChallenge as acceptchallenge,
|
||||
getCustomTokenFunction as getcustomtoken,
|
||||
}
|
||||
|
|
42
functions/src/mana-bonus-email.ts
Normal file
42
functions/src/mana-bonus-email.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import * as dayjs from 'dayjs'
|
||||
|
||||
import { getPrivateUser } from './utils'
|
||||
import { sendOneWeekBonusEmail } from './emails'
|
||||
import { User } from 'common/user'
|
||||
|
||||
export const manabonusemail = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
.pubsub.schedule('0 9 * * 1-7')
|
||||
.onRun(async () => {
|
||||
await sendOneWeekEmails()
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function sendOneWeekEmails() {
|
||||
const oneWeekAgo = dayjs().subtract(1, 'week').valueOf()
|
||||
const twoWeekAgo = dayjs().subtract(2, 'weeks').valueOf()
|
||||
|
||||
const userDocs = await firestore
|
||||
.collection('users')
|
||||
.where('createdTime', '<=', oneWeekAgo)
|
||||
.get()
|
||||
|
||||
for (const user of userDocs.docs.map((d) => d.data() as User)) {
|
||||
if (user.createdTime < twoWeekAgo) continue
|
||||
|
||||
const privateUser = await getPrivateUser(user.id)
|
||||
if (!privateUser || privateUser.manaBonusEmailSent) continue
|
||||
|
||||
await firestore
|
||||
.collection('private-users')
|
||||
.doc(user.id)
|
||||
.update({ manaBonusEmailSent: true })
|
||||
|
||||
console.log('sending m$ bonus email to', user.username)
|
||||
await sendOneWeekBonusEmail(user, privateUser)
|
||||
return
|
||||
}
|
||||
}
|
|
@ -1,13 +1,13 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
import { compact, uniq } from 'lodash'
|
||||
import { getContract, getUser, getValues } from './utils'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { sendNewCommentEmail } from './emails'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Answer } from '../../common/answer'
|
||||
import { createNotification } from './create-notification'
|
||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
|
@ -68,18 +68,22 @@ export const onCreateCommentOnContract = functions
|
|||
? 'answer'
|
||||
: undefined
|
||||
|
||||
const relatedUserId = comment.replyToCommentId
|
||||
const repliedUserId = comment.replyToCommentId
|
||||
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
||||
: answer?.userId
|
||||
|
||||
const recipients = uniq(
|
||||
compact([...parseMentions(comment.content), repliedUserId])
|
||||
)
|
||||
|
||||
await createNotification(
|
||||
comment.id,
|
||||
'comment',
|
||||
'created',
|
||||
commentCreator,
|
||||
eventId,
|
||||
comment.text,
|
||||
{ contract, relatedSourceType, relatedUserId }
|
||||
richTextToString(comment.content),
|
||||
{ contract, relatedSourceType, recipients }
|
||||
)
|
||||
|
||||
const recipientUserIds = uniq([
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
|
|||
import { getUser } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { richTextToString } from '../../common/util/parse'
|
||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
|
||||
export const onCreateContract = functions.firestore
|
||||
|
@ -14,13 +14,16 @@ export const onCreateContract = functions.firestore
|
|||
const contractCreator = await getUser(contract.creatorId)
|
||||
if (!contractCreator) throw new Error('Could not find contract creator')
|
||||
|
||||
const desc = contract.description as JSONContent
|
||||
const mentioned = parseMentions(desc)
|
||||
|
||||
await createNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'created',
|
||||
contractCreator,
|
||||
eventId,
|
||||
richTextToString(contract.description as JSONContent),
|
||||
{ contract }
|
||||
richTextToString(desc),
|
||||
{ contract, recipients: mentioned }
|
||||
)
|
||||
})
|
||||
|
|
|
@ -12,19 +12,17 @@ export const onCreateGroup = functions.firestore
|
|||
const groupCreator = await getUser(group.creatorId)
|
||||
if (!groupCreator) throw new Error('Could not find group creator')
|
||||
// create notifications for all members of the group
|
||||
for (const memberId of group.memberIds) {
|
||||
await createNotification(
|
||||
group.id,
|
||||
'group',
|
||||
'created',
|
||||
groupCreator,
|
||||
eventId,
|
||||
group.about,
|
||||
{
|
||||
relatedUserId: memberId,
|
||||
slug: group.slug,
|
||||
title: group.name,
|
||||
}
|
||||
)
|
||||
}
|
||||
await createNotification(
|
||||
group.id,
|
||||
'group',
|
||||
'created',
|
||||
groupCreator,
|
||||
eventId,
|
||||
group.about,
|
||||
{
|
||||
recipients: group.memberIds,
|
||||
slug: group.slug,
|
||||
title: group.name,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
|
@ -30,7 +30,7 @@ export const onFollowUser = functions.firestore
|
|||
followingUser,
|
||||
eventId,
|
||||
'',
|
||||
{ relatedUserId: follow.userId }
|
||||
{ recipients: [follow.userId] }
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { Group } from '../../common/group'
|
||||
import { getContract } from './utils'
|
||||
import { uniq } from 'lodash'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const onUpdateGroup = functions.firestore
|
||||
|
@ -9,7 +11,7 @@ export const onUpdateGroup = functions.firestore
|
|||
const prevGroup = change.before.data() as Group
|
||||
const group = change.after.data() as Group
|
||||
|
||||
// ignore the update we just made
|
||||
// Ignore the activity update we just made
|
||||
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
||||
return
|
||||
|
||||
|
@ -27,3 +29,23 @@ export const onUpdateGroup = functions.firestore
|
|||
.doc(group.id)
|
||||
.update({ mostRecentActivityTime: Date.now() })
|
||||
})
|
||||
|
||||
export async function removeGroupLinks(group: Group, contractIds: string[]) {
|
||||
for (const contractId of contractIds) {
|
||||
const contract = await getContract(contractId)
|
||||
await firestore
|
||||
.collection('contracts')
|
||||
.doc(contractId)
|
||||
.update({
|
||||
groupSlugs: uniq([
|
||||
...(contract?.groupSlugs?.filter((slug) => slug !== group.slug) ??
|
||||
[]),
|
||||
]),
|
||||
groupLinks: [
|
||||
...(contract?.groupLinks?.filter(
|
||||
(link) => link.groupId !== group.id
|
||||
) ?? []),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
groupPayoutsByUser,
|
||||
Payout,
|
||||
} from '../../common/payouts'
|
||||
import { isAdmin } from '../../common/envs/constants'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
|
@ -69,8 +70,6 @@ const opts = { secrets: ['MAILGUN_KEY'] }
|
|||
|
||||
export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||
const { contractId } = validate(bodySchema, req.body)
|
||||
const userId = auth.uid
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await contractDoc.get()
|
||||
if (!contractSnap.exists)
|
||||
|
@ -83,7 +82,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|||
req.body
|
||||
)
|
||||
|
||||
if (creatorId !== userId)
|
||||
if (creatorId !== auth.uid && !isAdmin(auth.uid))
|
||||
throw new APIError(403, 'User is not creator of contract')
|
||||
|
||||
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
||||
|
|
25
functions/src/scripts/backfill-group-ids.ts
Normal file
25
functions/src/scripts/backfill-group-ids.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
// We have some groups without IDs. Let's fill them in.
|
||||
|
||||
import * as admin from 'firebase-admin'
|
||||
import { initAdmin } from './script-init'
|
||||
import { log, writeAsync } from '../utils'
|
||||
|
||||
initAdmin()
|
||||
const firestore = admin.firestore()
|
||||
|
||||
if (require.main === module) {
|
||||
const groupsQuery = firestore.collection('groups')
|
||||
groupsQuery.get().then(async (groupSnaps) => {
|
||||
log(`Loaded ${groupSnaps.size} groups.`)
|
||||
const needsFilling = groupSnaps.docs.filter((ct) => {
|
||||
return !('id' in ct.data())
|
||||
})
|
||||
log(`${needsFilling.length} groups need IDs.`)
|
||||
const updates = needsFilling.map((group) => {
|
||||
return { doc: group.ref, fields: { id: group.id } }
|
||||
})
|
||||
log(`Updating ${updates.length} groups.`)
|
||||
await writeAsync(firestore, updates)
|
||||
log(`Updated all groups.`)
|
||||
})
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { sumBy, uniq } from 'lodash'
|
||||
import { mapValues, groupBy, sumBy, uniq } from 'lodash'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
|
||||
|
@ -9,7 +9,7 @@ import { getCpmmSellBetInfo } from '../../common/sell-bet'
|
|||
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
||||
import { getValues, log } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { floatingLesserEqual } from '../../common/util/math'
|
||||
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
||||
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
|
@ -17,7 +17,7 @@ import { redeemShares } from './redeem-shares'
|
|||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
shares: z.number().optional(), // leave it out to sell all shares
|
||||
outcome: z.enum(['YES', 'NO']),
|
||||
outcome: z.enum(['YES', 'NO']).optional(), // leave it out to sell whichever you have
|
||||
})
|
||||
|
||||
export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||
|
@ -46,9 +46,31 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
throw new APIError(400, 'Trading is closed.')
|
||||
|
||||
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
|
||||
const betsByOutcome = groupBy(userBets, (bet) => bet.outcome)
|
||||
const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
|
||||
sumBy(bets, (b) => b.shares)
|
||||
)
|
||||
|
||||
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
|
||||
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
|
||||
let chosenOutcome: 'YES' | 'NO'
|
||||
if (outcome != null) {
|
||||
chosenOutcome = outcome
|
||||
} else {
|
||||
const nonzeroShares = Object.entries(sharesByOutcome).filter(
|
||||
([_k, v]) => !floatingEqual(0, v)
|
||||
)
|
||||
if (nonzeroShares.length == 0) {
|
||||
throw new APIError(400, "You don't own any shares in this market.")
|
||||
}
|
||||
if (nonzeroShares.length > 1) {
|
||||
throw new APIError(
|
||||
400,
|
||||
`You own multiple kinds of shares, but did not specify which to sell.`
|
||||
)
|
||||
}
|
||||
chosenOutcome = nonzeroShares[0][0] as 'YES' | 'NO'
|
||||
}
|
||||
|
||||
const maxShares = sharesByOutcome[chosenOutcome]
|
||||
const sharesToSell = shares ?? maxShares
|
||||
|
||||
if (!floatingLesserEqual(sharesToSell, maxShares))
|
||||
|
@ -63,7 +85,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
||||
soldShares,
|
||||
outcome,
|
||||
chosenOutcome,
|
||||
contract,
|
||||
prevLoanAmount,
|
||||
unfilledBets
|
||||
|
|
|
@ -26,9 +26,10 @@ export const sendTemplateEmail = (
|
|||
subject: string,
|
||||
templateId: string,
|
||||
templateData: Record<string, string>,
|
||||
options?: { from: string }
|
||||
options?: Partial<mailgun.messages.SendTemplateData>
|
||||
) => {
|
||||
const data = {
|
||||
const data: mailgun.messages.SendTemplateData = {
|
||||
...options,
|
||||
from: options?.from ?? 'Manifold Markets <info@manifold.markets>',
|
||||
to,
|
||||
subject,
|
||||
|
@ -36,6 +37,7 @@ export const sendTemplateEmail = (
|
|||
'h:X-Mailgun-Variables': JSON.stringify(templateData),
|
||||
}
|
||||
const mg = initMailgun()
|
||||
|
||||
return mg.messages().send(data, (error) => {
|
||||
if (error) console.log('Error sending email', error)
|
||||
else console.log('Sent template email', templateId, to, subject)
|
||||
|
|
|
@ -25,6 +25,8 @@ import { creategroup } from './create-group'
|
|||
import { resolvemarket } from './resolve-market'
|
||||
import { unsubscribe } from './unsubscribe'
|
||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||
import { getcurrentuser } from './get-current-user'
|
||||
import { getcustomtoken } from './get-custom-token'
|
||||
|
||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||
const app = express()
|
||||
|
@ -62,6 +64,8 @@ addJsonEndpointRoute('/creategroup', creategroup)
|
|||
addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
||||
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
||||
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
||||
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
||||
addEndpointRoute('/getcustomtoken', getcustomtoken)
|
||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||
|
||||
app.listen(PORT)
|
||||
|
|
|
@ -1,32 +1,35 @@
|
|||
# Installing
|
||||
1. `yarn install`
|
||||
2. `yarn start`
|
||||
3. `Y` to `Set up and develop “~path/to/the/repo/manifold”? [Y/n]`
|
||||
4. `Manifold Markets` to `Which scope should contain your project? [Y/n] `
|
||||
5. `Y` to `Link to existing project? [Y/n] `
|
||||
6. `opengraph-image` to `What’s the name of your existing project?`
|
||||
|
||||
# Quickstart
|
||||
|
||||
1. To get started: `yarn install`
|
||||
2. To test locally: `yarn start`
|
||||
1. To test locally: `yarn start`
|
||||
The local image preview is broken for some reason; but the service works.
|
||||
E.g. try `http://localhost:3000/manifold.png`
|
||||
3. To deploy: push to Github
|
||||
|
||||
For more info, see Contributing.md
|
||||
|
||||
- note2: You may have to configure Vercel the first time:
|
||||
|
||||
```
|
||||
$ yarn start
|
||||
yarn run v1.22.10
|
||||
$ cd .. && vercel dev
|
||||
Vercel CLI 23.1.2 dev (beta) — https://vercel.com/feedback
|
||||
? Set up and develop “~/Code/mantic”? [Y/n] y
|
||||
? Which scope should contain your project? Mantic Markets
|
||||
? Found project “mantic/mantic”. Link to it? [Y/n] n
|
||||
? Link to different existing project? [Y/n] y
|
||||
? What’s the name of your existing project? manifold-og-image
|
||||
```
|
||||
|
||||
- note2: (Not `dev` because that's reserved for Vercel)
|
||||
- note3: (Or `cd .. && vercel --prod`, I think)
|
||||
2. To deploy: push to Github
|
||||
- note: (Not `dev` because that's reserved for Vercel)
|
||||
- note2: (Or `cd .. && vercel --prod`, I think)
|
||||
|
||||
For more info, see Contributing.md
|
||||
(Everything below is from the original repo)
|
||||
|
||||
# Development
|
||||
- Code of interest is contained in the `api/_lib` directory, i.e. `template.ts` is the page that renders the UI.
|
||||
- Edit `parseRequest(req: IncomingMessage)` in `parser.ts` to add/edit query parameters.
|
||||
- Note: When testing a remote branch on vercel, the og-image previews that apps load will point to
|
||||
`https://manifold-og-image.vercel.app/m.png?question=etc.`, (see relevant code in `SEO.tsx`) and not your remote branch.
|
||||
You have to find your opengraph-image branch's url and replace the part before `m.png` with it.
|
||||
- You can also preview the image locally, e.g. `http://localhost:3000/m.png?question=etc.`
|
||||
- Every time you change the template code you'll have to change the query parameter slightly as the image will likely be cached.
|
||||
- You can find your remote branch's opengraph-image url by click `Visit Preview` on Github:
|
||||

|
||||
|
||||
|
||||
# [Open Graph Image as a Service](https://og-image.vercel.app)
|
||||
|
||||
<a href="https://twitter.com/vercel">
|
||||
|
|
203
og-image/api/_lib/challenge-template.ts
Normal file
203
og-image/api/_lib/challenge-template.ts
Normal file
|
@ -0,0 +1,203 @@
|
|||
import { sanitizeHtml } from './sanitizer'
|
||||
import { ParsedRequest } from './types'
|
||||
|
||||
function getCss(theme: string, fontSize: string) {
|
||||
let background = 'white'
|
||||
let foreground = 'black'
|
||||
let radial = 'lightgray'
|
||||
|
||||
if (theme === 'dark') {
|
||||
background = 'black'
|
||||
foreground = 'white'
|
||||
radial = 'dimgray'
|
||||
}
|
||||
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
|
||||
return `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
|
||||
|
||||
body {
|
||||
background: ${background};
|
||||
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
|
||||
background-size: 100px 100px;
|
||||
height: 100vh;
|
||||
font-family: "Readex Pro", sans-serif;
|
||||
}
|
||||
|
||||
code {
|
||||
color: #D400FF;
|
||||
font-family: 'Vera';
|
||||
white-space: pre-wrap;
|
||||
letter-spacing: -5px;
|
||||
}
|
||||
|
||||
code:before, code:after {
|
||||
content: '\`';
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 75px;
|
||||
}
|
||||
|
||||
.plus {
|
||||
color: #BBB;
|
||||
font-family: Times New Roman, Verdana;
|
||||
font-size: 100px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
margin: 150px;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
margin: 0 .05em 0 .1em;
|
||||
vertical-align: -0.1em;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: 'Major Mono Display', monospace;
|
||||
font-size: ${sanitizeHtml(fontSize)};
|
||||
font-style: normal;
|
||||
color: ${foreground};
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.font-major-mono {
|
||||
font-family: "Major Mono Display", monospace;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #11b981;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export function getChallengeHtml(parsedReq: ParsedRequest) {
|
||||
const {
|
||||
theme,
|
||||
fontSize,
|
||||
question,
|
||||
creatorName,
|
||||
creatorAvatarUrl,
|
||||
challengerAmount,
|
||||
challengerOutcome,
|
||||
creatorAmount,
|
||||
creatorOutcome,
|
||||
acceptedName,
|
||||
acceptedAvatarUrl,
|
||||
} = parsedReq
|
||||
const MAX_QUESTION_CHARS = 78
|
||||
const truncatedQuestion =
|
||||
question.length > MAX_QUESTION_CHARS
|
||||
? question.slice(0, MAX_QUESTION_CHARS) + '...'
|
||||
: question
|
||||
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
|
||||
const hideAcceptedAvatar = acceptedAvatarUrl ? '' : 'hidden'
|
||||
const accepted = acceptedName !== ''
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Generated Image</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<style>
|
||||
${getCss(theme, fontSize)}
|
||||
</style>
|
||||
<body>
|
||||
<div class="px-24">
|
||||
|
||||
|
||||
<div class="flex flex-col justify-between gap-16 pt-2">
|
||||
<div class="flex flex-col text-indigo-700 mt-4 text-5xl leading-tight text-center">
|
||||
${truncatedQuestion}
|
||||
</div>
|
||||
<div class="flex flex-row grid grid-cols-3">
|
||||
<div class="flex flex-col justify-center items-center ${
|
||||
creatorOutcome === 'YES' ? 'text-primary' : 'text-red-500'
|
||||
}">
|
||||
|
||||
<!-- Creator user column-->
|
||||
<div class="flex flex-col align-bottom gap-6 items-center justify-center">
|
||||
<p class="text-gray-900 text-4xl">${creatorName}</p>
|
||||
<img
|
||||
class="h-36 w-36 rounded-full bg-white flex items-center justify-center ${hideAvatar}"
|
||||
src="${creatorAvatarUrl}"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row justify-center items-center gap-3 mt-6">
|
||||
<div class="text-5xl">${'M$' + creatorAmount}</div>
|
||||
<div class="text-4xl">${'on'}</div>
|
||||
<div class="text-5xl ">${creatorOutcome}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VS-->
|
||||
<div class="flex flex-col text-gray-900 text-6xl mt-8 text-center">
|
||||
VS
|
||||
</div>
|
||||
<div class="flex flex-col justify-center items-center ${
|
||||
challengerOutcome === 'YES' ? 'text-primary' : 'text-red-500'
|
||||
}">
|
||||
|
||||
<!-- Unaccepted user column-->
|
||||
<div class="flex flex-col align-bottom gap-6 items-center justify-center
|
||||
${accepted ? 'hidden' : ''}">
|
||||
<p class="text-gray-900 text-4xl">You</p>
|
||||
<img
|
||||
class="h-36 w-36 rounded-full bg-white flex items-center justify-center "
|
||||
src="https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<!-- Accepted user column-->
|
||||
<div class="flex flex-col align-bottom gap-6 items-center justify-center">
|
||||
<p class="text-gray-900 text-4xl">${acceptedName}</p>
|
||||
<img
|
||||
class="h-36 w-36 rounded-full bg-white flex items-center justify-center ${hideAcceptedAvatar}"
|
||||
src="${acceptedAvatarUrl}"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row justify-center items-center gap-3 mt-6">
|
||||
<div class="text-5xl">${'M$' + challengerAmount}</div>
|
||||
<div class="text-4xl">${'on'}</div>
|
||||
<div class="text-5xl ">${challengerOutcome}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- Manifold logo -->
|
||||
<div class="flex flex-row justify-center absolute bottom-4 left-[24rem]">
|
||||
<a class="flex flex-row gap-3" href="/">
|
||||
<img
|
||||
class="sm:h-12 sm:w-12"
|
||||
src="https://manifold.markets/logo.png"
|
||||
width="40"
|
||||
height="40"
|
||||
alt=''
|
||||
/>
|
||||
<div
|
||||
class="hidden sm:flex font-major-mono lowercase mt-1 sm:text-3xl md:whitespace-nowrap"
|
||||
>
|
||||
Manifold Markets
|
||||
</div></a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
|
@ -16,10 +16,19 @@ export function parseRequest(req: IncomingMessage) {
|
|||
// Attributes for Manifold card:
|
||||
question,
|
||||
probability,
|
||||
numericValue,
|
||||
metadata,
|
||||
creatorName,
|
||||
creatorUsername,
|
||||
creatorAvatarUrl,
|
||||
|
||||
// Challenge attributes:
|
||||
challengerAmount,
|
||||
challengerOutcome,
|
||||
creatorAmount,
|
||||
creatorOutcome,
|
||||
acceptedName,
|
||||
acceptedAvatarUrl,
|
||||
} = query || {}
|
||||
|
||||
if (Array.isArray(fontSize)) {
|
||||
|
@ -63,10 +72,17 @@ export function parseRequest(req: IncomingMessage) {
|
|||
question:
|
||||
getString(question) || 'Will you create a prediction market on Manifold?',
|
||||
probability: getString(probability),
|
||||
numericValue: getString(numericValue) || '',
|
||||
metadata: getString(metadata) || 'Jan 1 • M$ 123 pool',
|
||||
creatorName: getString(creatorName) || 'Manifold Markets',
|
||||
creatorUsername: getString(creatorUsername) || 'ManifoldMarkets',
|
||||
creatorAvatarUrl: getString(creatorAvatarUrl) || '',
|
||||
challengerAmount: getString(challengerAmount) || '',
|
||||
challengerOutcome: getString(challengerOutcome) || '',
|
||||
creatorAmount: getString(creatorAmount) || '',
|
||||
creatorOutcome: getString(creatorOutcome) || '',
|
||||
acceptedName: getString(acceptedName) || '',
|
||||
acceptedAvatarUrl: getString(acceptedAvatarUrl) || '',
|
||||
}
|
||||
parsedRequest.images = getDefaultImages(parsedRequest.images)
|
||||
return parsedRequest
|
||||
|
|
|
@ -91,6 +91,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
creatorName,
|
||||
creatorUsername,
|
||||
creatorAvatarUrl,
|
||||
numericValue,
|
||||
} = parsedReq
|
||||
const MAX_QUESTION_CHARS = 100
|
||||
const truncatedQuestion =
|
||||
|
@ -126,7 +127,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mantic logo -->
|
||||
<!-- Manifold logo -->
|
||||
<div class="absolute right-24 top-8">
|
||||
<a class="flex flex-row gap-3" href="/"
|
||||
><img
|
||||
|
@ -150,6 +151,12 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
<div class="flex flex-col text-primary">
|
||||
<div class="text-8xl">${probability}</div>
|
||||
<div class="text-4xl">${probability !== '' ? 'chance' : ''}</div>
|
||||
<span class='text-blue-500 text-center'>
|
||||
<div class="text-8xl ">${
|
||||
numericValue !== '' && probability === '' ? numericValue : ''
|
||||
}</div>
|
||||
<div class="text-4xl">${numericValue !== '' ? 'expected' : ''}</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,21 +1,29 @@
|
|||
export type FileType = "png" | "jpeg";
|
||||
export type Theme = "light" | "dark";
|
||||
export type FileType = 'png' | 'jpeg'
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
export interface ParsedRequest {
|
||||
fileType: FileType;
|
||||
text: string;
|
||||
theme: Theme;
|
||||
md: boolean;
|
||||
fontSize: string;
|
||||
images: string[];
|
||||
widths: string[];
|
||||
heights: string[];
|
||||
fileType: FileType
|
||||
text: string
|
||||
theme: Theme
|
||||
md: boolean
|
||||
fontSize: string
|
||||
images: string[]
|
||||
widths: string[]
|
||||
heights: string[]
|
||||
|
||||
// Attributes for Manifold card:
|
||||
question: string;
|
||||
probability: string;
|
||||
metadata: string;
|
||||
creatorName: string;
|
||||
creatorUsername: string;
|
||||
creatorAvatarUrl: string;
|
||||
question: string
|
||||
probability: string
|
||||
numericValue: string
|
||||
metadata: string
|
||||
creatorName: string
|
||||
creatorUsername: string
|
||||
creatorAvatarUrl: string
|
||||
// Challenge attributes:
|
||||
challengerAmount: string
|
||||
challengerOutcome: string
|
||||
creatorAmount: string
|
||||
creatorOutcome: string
|
||||
acceptedName: string
|
||||
acceptedAvatarUrl: string
|
||||
}
|
||||
|
|
|
@ -1,36 +1,38 @@
|
|||
import { IncomingMessage, ServerResponse } from "http";
|
||||
import { parseRequest } from "./_lib/parser";
|
||||
import { getScreenshot } from "./_lib/chromium";
|
||||
import { getHtml } from "./_lib/template";
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import { parseRequest } from './_lib/parser'
|
||||
import { getScreenshot } from './_lib/chromium'
|
||||
import { getHtml } from './_lib/template'
|
||||
import { getChallengeHtml } from './_lib/challenge-template'
|
||||
|
||||
const isDev = !process.env.AWS_REGION;
|
||||
const isHtmlDebug = process.env.OG_HTML_DEBUG === "1";
|
||||
const isDev = !process.env.AWS_REGION
|
||||
const isHtmlDebug = process.env.OG_HTML_DEBUG === '1'
|
||||
|
||||
export default async function handler(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse
|
||||
) {
|
||||
try {
|
||||
const parsedReq = parseRequest(req);
|
||||
const html = getHtml(parsedReq);
|
||||
const parsedReq = parseRequest(req)
|
||||
let html = getHtml(parsedReq)
|
||||
if (parsedReq.challengerOutcome) html = getChallengeHtml(parsedReq)
|
||||
if (isHtmlDebug) {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end(html);
|
||||
return;
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.end(html)
|
||||
return
|
||||
}
|
||||
const { fileType } = parsedReq;
|
||||
const file = await getScreenshot(html, fileType, isDev);
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", `image/${fileType}`);
|
||||
const { fileType } = parsedReq
|
||||
const file = await getScreenshot(html, fileType, isDev)
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Type', `image/${fileType}`)
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
'Cache-Control',
|
||||
`public, immutable, no-transform, s-maxage=31536000, max-age=31536000`
|
||||
);
|
||||
res.end(file);
|
||||
)
|
||||
res.end(file)
|
||||
} catch (e) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end("<h1>Internal Error</h1><p>Sorry, there was a problem</p>");
|
||||
console.error(e);
|
||||
res.statusCode = 500
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.end('<h1>Internal Error</h1><p>Sorry, there was a problem</p>')
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { ReactNode } from 'react'
|
||||
import Head from 'next/head'
|
||||
import { Challenge } from 'common/challenge'
|
||||
|
||||
export type OgCardProps = {
|
||||
question: string
|
||||
|
@ -8,27 +9,51 @@ export type OgCardProps = {
|
|||
creatorName: string
|
||||
creatorUsername: string
|
||||
creatorAvatarUrl?: string
|
||||
numericValue?: string
|
||||
}
|
||||
|
||||
function buildCardUrl(props: OgCardProps) {
|
||||
function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
|
||||
const {
|
||||
creatorAmount,
|
||||
acceptances,
|
||||
acceptorAmount,
|
||||
creatorOutcome,
|
||||
acceptorOutcome,
|
||||
} = challenge || {}
|
||||
const { userName, userAvatarUrl } = acceptances?.[0] ?? {}
|
||||
|
||||
const probabilityParam =
|
||||
props.probability === undefined
|
||||
? ''
|
||||
: `&probability=${encodeURIComponent(props.probability ?? '')}`
|
||||
|
||||
const numericValueParam =
|
||||
props.numericValue === undefined
|
||||
? ''
|
||||
: `&numericValue=${encodeURIComponent(props.numericValue ?? '')}`
|
||||
|
||||
const creatorAvatarUrlParam =
|
||||
props.creatorAvatarUrl === undefined
|
||||
? ''
|
||||
: `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}`
|
||||
|
||||
const challengeUrlParams = challenge
|
||||
? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` +
|
||||
`&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` +
|
||||
`&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}`
|
||||
: ''
|
||||
|
||||
// URL encode each of the props, then add them as query params
|
||||
return (
|
||||
`https://manifold-og-image.vercel.app/m.png` +
|
||||
`?question=${encodeURIComponent(props.question)}` +
|
||||
probabilityParam +
|
||||
numericValueParam +
|
||||
`&metadata=${encodeURIComponent(props.metadata)}` +
|
||||
`&creatorName=${encodeURIComponent(props.creatorName)}` +
|
||||
creatorAvatarUrlParam +
|
||||
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}`
|
||||
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}` +
|
||||
challengeUrlParams
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -38,8 +63,9 @@ export function SEO(props: {
|
|||
url?: string
|
||||
children?: ReactNode
|
||||
ogCardProps?: OgCardProps
|
||||
challenge?: Challenge
|
||||
}) {
|
||||
const { title, description, url, children, ogCardProps } = props
|
||||
const { title, description, url, children, ogCardProps, challenge } = props
|
||||
|
||||
return (
|
||||
<Head>
|
||||
|
@ -71,13 +97,13 @@ export function SEO(props: {
|
|||
<>
|
||||
<meta
|
||||
property="og:image"
|
||||
content={buildCardUrl(ogCardProps)}
|
||||
content={buildCardUrl(ogCardProps, challenge)}
|
||||
key="image1"
|
||||
/>
|
||||
<meta name="twitter:card" content="summary_large_image" key="card" />
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content={buildCardUrl(ogCardProps)}
|
||||
content={buildCardUrl(ogCardProps, challenge)}
|
||||
key="image2"
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { createContext, useEffect } from 'react'
|
||||
import { ReactNode, createContext, useEffect } from 'react'
|
||||
import { User } from 'common/user'
|
||||
import { onIdTokenChanged } from 'firebase/auth'
|
||||
import {
|
||||
|
@ -7,7 +7,7 @@ import {
|
|||
getUser,
|
||||
setCachedReferralInfoForUser,
|
||||
} from 'web/lib/firebase/users'
|
||||
import { deleteAuthCookies, setAuthCookies } from 'web/lib/firebase/auth'
|
||||
import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth'
|
||||
import { createUser } from 'web/lib/firebase/api'
|
||||
import { randomString } from 'common/util/random'
|
||||
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
||||
|
@ -28,20 +28,28 @@ const ensureDeviceToken = () => {
|
|||
return deviceToken
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthUser>(null)
|
||||
|
||||
export function AuthProvider({ children }: any) {
|
||||
const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(undefined)
|
||||
export const AuthContext = createContext<AuthUser>(undefined)
|
||||
|
||||
export function AuthProvider(props: {
|
||||
children: ReactNode
|
||||
serverUser?: AuthUser
|
||||
}) {
|
||||
const { children, serverUser } = props
|
||||
const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(serverUser)
|
||||
useEffect(() => {
|
||||
const cachedUser = localStorage.getItem(CACHED_USER_KEY)
|
||||
setAuthUser(cachedUser && JSON.parse(cachedUser))
|
||||
}, [setAuthUser])
|
||||
if (serverUser === undefined) {
|
||||
const cachedUser = localStorage.getItem(CACHED_USER_KEY)
|
||||
setAuthUser(cachedUser && JSON.parse(cachedUser))
|
||||
}
|
||||
}, [setAuthUser, serverUser])
|
||||
|
||||
useEffect(() => {
|
||||
return onIdTokenChanged(auth, async (fbUser) => {
|
||||
if (fbUser) {
|
||||
setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken)
|
||||
setTokenCookies({
|
||||
id: await fbUser.getIdToken(),
|
||||
refresh: fbUser.refreshToken,
|
||||
})
|
||||
let user = await getUser(fbUser.uid)
|
||||
if (!user) {
|
||||
const deviceToken = ensureDeviceToken()
|
||||
|
@ -54,7 +62,7 @@ export function AuthProvider({ children }: any) {
|
|||
setCachedReferralInfoForUser(user)
|
||||
} else {
|
||||
// User logged out; reset to null
|
||||
deleteAuthCookies()
|
||||
deleteTokenCookies()
|
||||
setAuthUser(null)
|
||||
localStorage.removeItem(CACHED_USER_KEY)
|
||||
}
|
||||
|
|
|
@ -16,8 +16,7 @@ import {
|
|||
import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet'
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
import { Bet, LimitBet } from 'common/bet'
|
||||
import { APIError, placeBet } from 'web/lib/firebase/api'
|
||||
import { sellShares } from 'web/lib/firebase/api'
|
||||
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
|
||||
import { AmountInput, BuyAmountInput } from './amount-input'
|
||||
import { InfoTooltip } from './info-tooltip'
|
||||
import {
|
||||
|
@ -351,7 +350,7 @@ function BuyPanel(props: {
|
|||
{user && (
|
||||
<button
|
||||
className={clsx(
|
||||
'btn flex-1',
|
||||
'btn mb-2 flex-1',
|
||||
betDisabled
|
||||
? 'btn-disabled'
|
||||
: outcome === 'YES'
|
||||
|
|
|
@ -157,9 +157,7 @@ export function BetsList(props: {
|
|||
(c) => contractsMetrics[c.id].netPayout
|
||||
)
|
||||
|
||||
const totalPortfolio = currentNetInvestment + user.balance
|
||||
|
||||
const totalPnl = totalPortfolio - user.totalDeposits
|
||||
const totalPnl = user.profitCached.allTime
|
||||
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
|
||||
const investedProfitPercent =
|
||||
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
|
||||
|
@ -354,7 +352,7 @@ function ContractBets(props: {
|
|||
<LimitOrderTable
|
||||
contract={contract}
|
||||
limitBets={limitBets}
|
||||
isYou={true}
|
||||
isYou={isYourBets}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -5,8 +5,16 @@ export function Button(props: {
|
|||
className?: string
|
||||
onClick?: () => void
|
||||
children?: ReactNode
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
color?: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray' | 'gray-white'
|
||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
color?:
|
||||
| 'green'
|
||||
| 'red'
|
||||
| 'blue'
|
||||
| 'indigo'
|
||||
| 'yellow'
|
||||
| 'gray'
|
||||
| 'gradient'
|
||||
| 'gray-white'
|
||||
type?: 'button' | 'reset' | 'submit'
|
||||
disabled?: boolean
|
||||
}) {
|
||||
|
@ -21,11 +29,13 @@ export function Button(props: {
|
|||
} = props
|
||||
|
||||
const sizeClasses = {
|
||||
'2xs': 'px-2 py-1 text-xs',
|
||||
xs: 'px-2.5 py-1.5 text-sm',
|
||||
sm: 'px-3 py-2 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-4 py-2 text-base',
|
||||
xl: 'px-6 py-3 text-base',
|
||||
'2xl': 'px-6 py-3 text-xl',
|
||||
}[size]
|
||||
|
||||
return (
|
||||
|
@ -40,7 +50,10 @@ export function Button(props: {
|
|||
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
|
||||
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
|
||||
color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
||||
color === 'gray-white' && 'bg-white text-gray-500 hover:bg-gray-200',
|
||||
color === 'gradient' &&
|
||||
'bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
|
||||
color === 'gray-white' &&
|
||||
'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200',
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
|
|
|
@ -15,8 +15,8 @@ export function PillButton(props: {
|
|||
className={clsx(
|
||||
'cursor-pointer select-none whitespace-nowrap rounded-full',
|
||||
selected
|
||||
? ['text-white', color ?? 'bg-gray-700']
|
||||
: 'bg-gray-100 hover:bg-gray-200',
|
||||
? ['text-white', color ?? 'bg-greyscale-6']
|
||||
: 'bg-greyscale-2 hover:bg-greyscale-3',
|
||||
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
|
|
125
web/components/challenges/accept-challenge-button.tsx
Normal file
125
web/components/challenges/accept-challenge-button.tsx
Normal file
|
@ -0,0 +1,125 @@
|
|||
import { User } from 'common/user'
|
||||
import { Contract } from 'common/contract'
|
||||
import { Challenge } from 'common/challenge'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SignUpPrompt } from 'web/components/sign-up-prompt'
|
||||
import { acceptChallenge, APIError } from 'web/lib/firebase/api'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Title } from 'web/components/title'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { Button } from 'web/components/button'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export function AcceptChallengeButton(props: {
|
||||
user: User | null | undefined
|
||||
contract: Contract
|
||||
challenge: Challenge
|
||||
}) {
|
||||
const { user, challenge, contract } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const [errorText, setErrorText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { acceptorAmount, creatorAmount } = challenge
|
||||
|
||||
useEffect(() => {
|
||||
setErrorText('')
|
||||
}, [open])
|
||||
|
||||
if (!user) return <SignUpPrompt label="Accept this bet" className="mt-4" />
|
||||
|
||||
const iAcceptChallenge = () => {
|
||||
setLoading(true)
|
||||
if (user.id === challenge.creatorId) {
|
||||
setErrorText('You cannot accept your own challenge!')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
acceptChallenge({
|
||||
contractId: contract.id,
|
||||
challengeSlug: challenge.slug,
|
||||
outcomeType: contract.outcomeType,
|
||||
closeTime: contract.closeTime,
|
||||
})
|
||||
.then((r) => {
|
||||
console.log('accepted challenge. Result:', r)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
setLoading(false)
|
||||
if (e instanceof APIError) {
|
||||
setErrorText(e.toString())
|
||||
} else {
|
||||
console.error(e)
|
||||
setErrorText('Error accepting challenge')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} setOpen={(newOpen) => setOpen(newOpen)} size={'sm'}>
|
||||
<Col className="gap-4 rounded-md bg-white px-8 py-6">
|
||||
<Col className={'gap-4'}>
|
||||
<div className={'flex flex-row justify-start '}>
|
||||
<Title text={"So you're in?"} className={'!my-2'} />
|
||||
</div>
|
||||
<Col className="w-full items-center justify-start gap-2">
|
||||
<Row className={'w-full justify-start gap-20'}>
|
||||
<span className={'min-w-[4rem] font-bold'}>Cost to you:</span>{' '}
|
||||
<span className={'text-red-500'}>
|
||||
{formatMoney(acceptorAmount)}
|
||||
</span>
|
||||
</Row>
|
||||
<Col className={'w-full items-center justify-start'}>
|
||||
<Row className={'w-full justify-start gap-10'}>
|
||||
<span className={'min-w-[4rem] font-bold'}>
|
||||
Potential payout:
|
||||
</span>{' '}
|
||||
<Row className={'items-center justify-center'}>
|
||||
<span className={'text-primary'}>
|
||||
{formatMoney(creatorAmount + acceptorAmount)}
|
||||
</span>
|
||||
</Row>
|
||||
</Row>
|
||||
</Col>
|
||||
</Col>
|
||||
<Row className={'mt-4 justify-end gap-4'}>
|
||||
<Button
|
||||
color={'gray'}
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
className={clsx('whitespace-nowrap')}
|
||||
>
|
||||
I'm out
|
||||
</Button>
|
||||
<Button
|
||||
color={'indigo'}
|
||||
disabled={loading}
|
||||
onClick={() => iAcceptChallenge()}
|
||||
className={clsx('min-w-[6rem] whitespace-nowrap')}
|
||||
>
|
||||
I'm in
|
||||
</Button>
|
||||
</Row>
|
||||
<Row>
|
||||
<span className={'text-error'}>{errorText}</span>
|
||||
</Row>
|
||||
</Col>
|
||||
</Col>
|
||||
</Modal>
|
||||
|
||||
{challenge.creatorId != user.id && (
|
||||
<Button
|
||||
color="gradient"
|
||||
size="2xl"
|
||||
onClick={() => setOpen(true)}
|
||||
className={clsx('whitespace-nowrap')}
|
||||
>
|
||||
Accept bet
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
259
web/components/challenges/create-challenge-modal.tsx
Normal file
259
web/components/challenges/create-challenge-modal.tsx
Normal file
|
@ -0,0 +1,259 @@
|
|||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { LinkIcon, SwitchVerticalIcon } from '@heroicons/react/outline'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { Title } from '../title'
|
||||
import { User } from 'common/user'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Button } from '../button'
|
||||
import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
|
||||
import { BinaryContract } from 'common/contract'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { NoLabel, YesLabel } from '../outcome-label'
|
||||
import { QRCode } from '../qr-code'
|
||||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { AmountInput } from '../amount-input'
|
||||
import { getProbability } from 'common/calculate'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
type challengeInfo = {
|
||||
amount: number
|
||||
expiresTime: number | null
|
||||
message: string
|
||||
outcome: 'YES' | 'NO' | number
|
||||
acceptorAmount: number
|
||||
}
|
||||
|
||||
export function CreateChallengeModal(props: {
|
||||
user: User | null | undefined
|
||||
contract: BinaryContract
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}) {
|
||||
const { user, contract, isOpen, setOpen } = props
|
||||
const [challengeSlug, setChallengeSlug] = useState('')
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="gap-4 rounded-md bg-white px-8 py-6">
|
||||
{/*// add a sign up to challenge button?*/}
|
||||
{user && (
|
||||
<CreateChallengeForm
|
||||
user={user}
|
||||
contract={contract}
|
||||
onCreate={async (newChallenge) => {
|
||||
const challenge = await createChallenge({
|
||||
creator: user,
|
||||
creatorAmount: newChallenge.amount,
|
||||
expiresTime: newChallenge.expiresTime,
|
||||
message: newChallenge.message,
|
||||
acceptorAmount: newChallenge.acceptorAmount,
|
||||
outcome: newChallenge.outcome,
|
||||
contract: contract,
|
||||
})
|
||||
if (challenge) {
|
||||
setChallengeSlug(getChallengeUrl(challenge))
|
||||
track('challenge created', {
|
||||
creator: user.username,
|
||||
amount: newChallenge.amount,
|
||||
contractId: contract.id,
|
||||
})
|
||||
}
|
||||
}}
|
||||
challengeSlug={challengeSlug}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateChallengeForm(props: {
|
||||
user: User
|
||||
contract: BinaryContract
|
||||
onCreate: (m: challengeInfo) => Promise<void>
|
||||
challengeSlug: string
|
||||
}) {
|
||||
const { user, onCreate, contract, challengeSlug } = props
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [finishedCreating, setFinishedCreating] = useState(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
|
||||
const defaultExpire = 'week'
|
||||
|
||||
const defaultMessage = `${user.name} is challenging you to a bet! Do you think ${contract.question}`
|
||||
|
||||
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
|
||||
expiresTime: dayjs().add(2, defaultExpire).valueOf(),
|
||||
outcome: 'YES',
|
||||
amount: 100,
|
||||
acceptorAmount: 100,
|
||||
message: defaultMessage,
|
||||
})
|
||||
useEffect(() => {
|
||||
setError('')
|
||||
}, [challengeInfo])
|
||||
|
||||
return (
|
||||
<>
|
||||
{!finishedCreating && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (user.balance < challengeInfo.amount) {
|
||||
setError('You do not have enough mana to create this challenge')
|
||||
return
|
||||
}
|
||||
setIsCreating(true)
|
||||
onCreate(challengeInfo).finally(() => setIsCreating(false))
|
||||
setFinishedCreating(true)
|
||||
}}
|
||||
>
|
||||
<Title className="!mt-2" text="Challenge bet " />
|
||||
|
||||
<div className="mb-8">
|
||||
Challenge a friend to bet on{' '}
|
||||
<span className="underline">{contract.question}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2">
|
||||
<div>You'll bet:</div>
|
||||
<Row
|
||||
className={
|
||||
'form-control w-full max-w-xs items-center justify-between gap-4 pr-3'
|
||||
}
|
||||
>
|
||||
<AmountInput
|
||||
amount={challengeInfo.amount || undefined}
|
||||
onChange={(newAmount) =>
|
||||
setChallengeInfo((m: challengeInfo) => {
|
||||
return {
|
||||
...m,
|
||||
amount: newAmount ?? 0,
|
||||
acceptorAmount: editingAcceptorAmount
|
||||
? m.acceptorAmount
|
||||
: newAmount ?? 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
error={undefined}
|
||||
label={'M$'}
|
||||
inputClassName="w-24"
|
||||
/>
|
||||
<span className={''}>on</span>
|
||||
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
|
||||
</Row>
|
||||
<Row className={'mt-3 max-w-xs justify-end'}>
|
||||
<Button
|
||||
color={'gray-white'}
|
||||
onClick={() =>
|
||||
setChallengeInfo((m: challengeInfo) => {
|
||||
return {
|
||||
...m,
|
||||
outcome: m.outcome === 'YES' ? 'NO' : 'YES',
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<SwitchVerticalIcon className={'h-6 w-6'} />
|
||||
</Button>
|
||||
</Row>
|
||||
<Row className={'items-center'}>If they bet:</Row>
|
||||
<Row className={'max-w-xs items-center justify-between gap-4 pr-3'}>
|
||||
<div className={'w-32 sm:mr-1'}>
|
||||
<AmountInput
|
||||
amount={challengeInfo.acceptorAmount || undefined}
|
||||
onChange={(newAmount) => {
|
||||
setEditingAcceptorAmount(true)
|
||||
|
||||
setChallengeInfo((m: challengeInfo) => {
|
||||
return {
|
||||
...m,
|
||||
acceptorAmount: newAmount ?? 0,
|
||||
}
|
||||
})
|
||||
}}
|
||||
error={undefined}
|
||||
label={'M$'}
|
||||
inputClassName="w-24"
|
||||
/>
|
||||
</div>
|
||||
<span>on</span>
|
||||
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
|
||||
</Row>
|
||||
</div>
|
||||
<Button
|
||||
size="2xs"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingAcceptorAmount(true)
|
||||
|
||||
const p = getProbability(contract)
|
||||
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
|
||||
const { amount } = challengeInfo
|
||||
const acceptorAmount = Math.round(amount / prob - amount)
|
||||
setChallengeInfo({ ...challengeInfo, acceptorAmount })
|
||||
}}
|
||||
>
|
||||
Use market odds
|
||||
</Button>
|
||||
|
||||
<div className="mt-8">
|
||||
If the challenge is accepted, whoever is right will earn{' '}
|
||||
<span className="font-semibold">
|
||||
{formatMoney(
|
||||
challengeInfo.acceptorAmount + challengeInfo.amount || 0
|
||||
)}
|
||||
</span>{' '}
|
||||
in total.
|
||||
</div>
|
||||
|
||||
<Row className="mt-8 items-center">
|
||||
<Button
|
||||
type="submit"
|
||||
color={'gradient'}
|
||||
size="xl"
|
||||
className={clsx(
|
||||
'whitespace-nowrap drop-shadow-md',
|
||||
isCreating ? 'disabled' : ''
|
||||
)}
|
||||
>
|
||||
Create challenge bet
|
||||
</Button>
|
||||
</Row>
|
||||
<Row className={'text-error'}>{error} </Row>
|
||||
</form>
|
||||
)}
|
||||
{finishedCreating && (
|
||||
<>
|
||||
<Title className="!my-0" text="Challenge Created!" />
|
||||
|
||||
<div>Share the challenge using the link.</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
copyToClipboard(challengeSlug)
|
||||
toast('Link copied to clipboard!')
|
||||
}}
|
||||
className={'btn btn-outline mb-4 whitespace-nowrap normal-case'}
|
||||
>
|
||||
<LinkIcon className={'mr-2 h-5 w-5'} />
|
||||
Copy link
|
||||
</button>
|
||||
|
||||
<QRCode url={challengeSlug} className="self-center" />
|
||||
<Row className={'gap-1 text-gray-500'}>
|
||||
See your other
|
||||
<SiteLink className={'underline'} href={'/challenges'}>
|
||||
challenges
|
||||
</SiteLink>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -8,8 +8,8 @@ import { RelativeTimestamp } from './relative-timestamp'
|
|||
import { UserLink } from './user-page'
|
||||
import { User } from 'common/user'
|
||||
import { Col } from './layout/col'
|
||||
import { Linkify } from './linkify'
|
||||
import { groupBy } from 'lodash'
|
||||
import { Content } from './editor'
|
||||
|
||||
export function UserCommentsList(props: {
|
||||
user: User
|
||||
|
@ -50,7 +50,8 @@ export function UserCommentsList(props: {
|
|||
|
||||
function ProfileComment(props: { comment: Comment; className?: string }) {
|
||||
const { comment, className } = props
|
||||
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
||||
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
|
||||
comment
|
||||
// TODO: find and attach relevant bets by comment betId at some point
|
||||
return (
|
||||
<Row className={className}>
|
||||
|
@ -64,7 +65,7 @@ function ProfileComment(props: { comment: Comment; className?: string }) {
|
|||
/>{' '}
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
</p>
|
||||
<Linkify text={text} />
|
||||
<Content content={content || text} />
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
|
|
|
@ -1,26 +1,14 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import algoliasearch from 'algoliasearch/lite'
|
||||
import {
|
||||
Configure,
|
||||
InstantSearch,
|
||||
SearchBox,
|
||||
SortBy,
|
||||
useInfiniteHits,
|
||||
useSortBy,
|
||||
} from 'react-instantsearch-hooks-web'
|
||||
|
||||
import { Contract } from 'common/contract'
|
||||
import {
|
||||
Sort,
|
||||
useInitialQueryAndSort,
|
||||
useUpdateQueryAndSort,
|
||||
} from '../hooks/use-sort-and-query-params'
|
||||
import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params'
|
||||
import {
|
||||
ContractHighlightOptions,
|
||||
ContractsGrid,
|
||||
} from './contract/contracts-list'
|
||||
} from './contract/contracts-grid'
|
||||
import { Row } from './layout/row'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
|
@ -30,8 +18,9 @@ import ContractSearchFirestore from 'web/pages/contract-search-firestore'
|
|||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { Group, NEW_USER_GROUP_SLUGS } from 'common/group'
|
||||
import { PillButton } from './buttons/pill-button'
|
||||
import { sortBy } from 'lodash'
|
||||
import { range, sortBy } from 'lodash'
|
||||
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
|
||||
import { Col } from './layout/col'
|
||||
|
||||
const searchClient = algoliasearch(
|
||||
'GJQPAYENIF',
|
||||
|
@ -39,17 +28,17 @@ const searchClient = algoliasearch(
|
|||
)
|
||||
|
||||
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
|
||||
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
|
||||
|
||||
const sortIndexes = [
|
||||
{ label: 'Newest', value: indexPrefix + 'contracts-newest' },
|
||||
// { label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
|
||||
{ label: 'Most popular', value: indexPrefix + 'contracts-score' },
|
||||
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' },
|
||||
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' },
|
||||
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' },
|
||||
{ label: 'Subsidy', value: indexPrefix + 'contracts-liquidity' },
|
||||
{ label: 'Close date', value: indexPrefix + 'contracts-close-date' },
|
||||
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
|
||||
const sortOptions = [
|
||||
{ label: 'Newest', value: 'newest' },
|
||||
{ label: 'Trending', value: 'score' },
|
||||
{ label: 'Most traded', value: 'most-traded' },
|
||||
{ label: '24h volume', value: '24-hour-vol' },
|
||||
{ label: 'Last updated', value: 'last-updated' },
|
||||
{ label: 'Subsidy', value: 'liquidity' },
|
||||
{ label: 'Close date', value: 'close-date' },
|
||||
{ label: 'Resolve date', value: 'resolve-date' },
|
||||
]
|
||||
export const DEFAULT_SORT = 'score'
|
||||
|
||||
|
@ -108,77 +97,154 @@ export function ContractSearch(props: {
|
|||
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
|
||||
|
||||
const follows = useFollows(user?.id)
|
||||
const { initialSort } = useInitialQueryAndSort(querySortOptions)
|
||||
|
||||
const sort = sortIndexes
|
||||
.map(({ value }) => value)
|
||||
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
|
||||
? initialSort
|
||||
: querySortOptions?.defaultSort ?? DEFAULT_SORT
|
||||
const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {}
|
||||
const { query, setQuery, sort, setSort } = useQueryAndSortParams({
|
||||
defaultSort,
|
||||
shouldLoadFromStorage,
|
||||
})
|
||||
|
||||
const [filter, setFilter] = useState<filter>(
|
||||
querySortOptions?.defaultFilter ?? 'open'
|
||||
)
|
||||
const pillsEnabled = !additionalFilter
|
||||
const pillsEnabled = !additionalFilter && !query
|
||||
|
||||
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
|
||||
|
||||
const selectFilter = (pill: string | undefined) => () => {
|
||||
const selectPill = (pill: string | undefined) => () => {
|
||||
setPillFilter(pill)
|
||||
setPage(0)
|
||||
track('select search category', { category: pill ?? 'all' })
|
||||
}
|
||||
|
||||
const { filters, numericFilters } = useMemo(() => {
|
||||
let filters = [
|
||||
filter === 'open' ? 'isResolved:false' : '',
|
||||
filter === 'closed' ? 'isResolved:false' : '',
|
||||
filter === 'resolved' ? 'isResolved:true' : '',
|
||||
additionalFilter?.creatorId
|
||||
? `creatorId:${additionalFilter.creatorId}`
|
||||
: '',
|
||||
additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '',
|
||||
additionalFilter?.groupSlug
|
||||
? `groupLinks.slug:${additionalFilter.groupSlug}`
|
||||
: '',
|
||||
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
|
||||
? `groupLinks.slug:${pillFilter}`
|
||||
: '',
|
||||
pillFilter === 'personal'
|
||||
? // Show contracts in groups that the user is a member of
|
||||
memberGroupSlugs
|
||||
.map((slug) => `groupLinks.slug:${slug}`)
|
||||
// Show contracts created by users the user follows
|
||||
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
|
||||
// Show contracts bet on by users the user follows
|
||||
.concat(
|
||||
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
|
||||
)
|
||||
: '',
|
||||
// Subtract contracts you bet on from For you.
|
||||
pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '',
|
||||
pillFilter === 'your-bets' && user
|
||||
? // Show contracts bet on by the user
|
||||
`uniqueBettorIds:${user.id}`
|
||||
: '',
|
||||
].filter((f) => f)
|
||||
// Hack to make Algolia work.
|
||||
filters = ['', ...filters]
|
||||
const additionalFilters = [
|
||||
additionalFilter?.creatorId
|
||||
? `creatorId:${additionalFilter.creatorId}`
|
||||
: '',
|
||||
additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '',
|
||||
additionalFilter?.groupSlug
|
||||
? `groupLinks.slug:${additionalFilter.groupSlug}`
|
||||
: '',
|
||||
]
|
||||
const facetFilters = query
|
||||
? additionalFilters
|
||||
: [
|
||||
...additionalFilters,
|
||||
filter === 'open' ? 'isResolved:false' : '',
|
||||
filter === 'closed' ? 'isResolved:false' : '',
|
||||
filter === 'resolved' ? 'isResolved:true' : '',
|
||||
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
|
||||
? `groupLinks.slug:${pillFilter}`
|
||||
: '',
|
||||
pillFilter === 'personal'
|
||||
? // Show contracts in groups that the user is a member of
|
||||
memberGroupSlugs
|
||||
.map((slug) => `groupLinks.slug:${slug}`)
|
||||
// Show contracts created by users the user follows
|
||||
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
|
||||
// Show contracts bet on by users the user follows
|
||||
.concat(
|
||||
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
|
||||
)
|
||||
: '',
|
||||
// Subtract contracts you bet on from For you.
|
||||
pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '',
|
||||
pillFilter === 'your-bets' && user
|
||||
? // Show contracts bet on by the user
|
||||
`uniqueBettorIds:${user.id}`
|
||||
: '',
|
||||
].filter((f) => f)
|
||||
|
||||
const numericFilters = [
|
||||
filter === 'open' ? `closeTime > ${Date.now()}` : '',
|
||||
filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
|
||||
].filter((f) => f)
|
||||
|
||||
return { filters, numericFilters }
|
||||
}, [
|
||||
filter,
|
||||
Object.values(additionalFilter ?? {}).join(','),
|
||||
memberGroupSlugs.join(','),
|
||||
(follows ?? []).join(','),
|
||||
pillFilter,
|
||||
])
|
||||
const numericFilters = query
|
||||
? []
|
||||
: [
|
||||
filter === 'open' ? `closeTime > ${Date.now()}` : '',
|
||||
filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
|
||||
].filter((f) => f)
|
||||
|
||||
const indexName = `${indexPrefix}contracts-${sort}`
|
||||
const index = useMemo(() => searchClient.initIndex(indexName), [indexName])
|
||||
const searchIndex = useMemo(
|
||||
() => searchClient.initIndex(searchIndexName),
|
||||
[searchIndexName]
|
||||
)
|
||||
|
||||
const [page, setPage] = useState(0)
|
||||
const [numPages, setNumPages] = useState(1)
|
||||
const [hitsByPage, setHitsByPage] = useState<{ [page: string]: Contract[] }>(
|
||||
{}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let wasMostRecentQuery = true
|
||||
const algoliaIndex = query ? searchIndex : index
|
||||
|
||||
algoliaIndex
|
||||
.search(query, {
|
||||
facetFilters,
|
||||
numericFilters,
|
||||
page,
|
||||
hitsPerPage: 20,
|
||||
})
|
||||
.then((results) => {
|
||||
if (!wasMostRecentQuery) return
|
||||
|
||||
if (page === 0) {
|
||||
setHitsByPage({
|
||||
[0]: results.hits as any as Contract[],
|
||||
})
|
||||
} else {
|
||||
setHitsByPage((hitsByPage) => ({
|
||||
...hitsByPage,
|
||||
[page]: results.hits,
|
||||
}))
|
||||
}
|
||||
setNumPages(results.nbPages)
|
||||
})
|
||||
return () => {
|
||||
wasMostRecentQuery = false
|
||||
}
|
||||
// Note numeric filters are unique based on current time, so can't compare
|
||||
// them by value.
|
||||
}, [query, page, index, searchIndex, JSON.stringify(facetFilters), filter])
|
||||
|
||||
const loadMore = () => {
|
||||
if (page >= numPages - 1) return
|
||||
|
||||
const haveLoadedCurrentPage = hitsByPage[page]
|
||||
if (haveLoadedCurrentPage) setPage(page + 1)
|
||||
}
|
||||
|
||||
const hits = range(0, page + 1)
|
||||
.map((p) => hitsByPage[p] ?? [])
|
||||
.flat()
|
||||
|
||||
const contracts = hits.filter(
|
||||
(c) => !additionalFilter?.excludeContractIds?.includes(c.id)
|
||||
)
|
||||
|
||||
const showTime =
|
||||
sort === 'close-date' || sort === 'resolve-date' ? sort : undefined
|
||||
|
||||
const updateQuery = (newQuery: string) => {
|
||||
setQuery(newQuery)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const selectFilter = (newFilter: filter) => {
|
||||
if (newFilter === filter) return
|
||||
setFilter(newFilter)
|
||||
setPage(0)
|
||||
trackCallback('select search filter', { filter: newFilter })
|
||||
}
|
||||
|
||||
const selectSort = (newSort: Sort) => {
|
||||
if (newSort === sort) return
|
||||
|
||||
setPage(0)
|
||||
setSort(newSort)
|
||||
track('select sort', { sort: newSort })
|
||||
}
|
||||
|
||||
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||
return (
|
||||
|
@ -190,44 +256,40 @@ export function ContractSearch(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<InstantSearch searchClient={searchClient} indexName={indexName}>
|
||||
<Col>
|
||||
<Row className="gap-1 sm:gap-2">
|
||||
<SearchBox
|
||||
className="flex-1"
|
||||
placeholder={showPlaceHolder ? `Search ${filter} contracts` : ''}
|
||||
classNames={{
|
||||
form: 'before:top-6',
|
||||
input: '!pl-10 !input !input-bordered shadow-none w-[100px]',
|
||||
resetIcon: 'mt-2 hidden sm:flex',
|
||||
}}
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => updateQuery(e.target.value)}
|
||||
placeholder={showPlaceHolder ? `Search ${filter} markets` : ''}
|
||||
className="input input-bordered w-full"
|
||||
/>
|
||||
{/*// TODO track WHICH filter users are using*/}
|
||||
<select
|
||||
className="!select !select-bordered"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as filter)}
|
||||
onBlur={trackCallback('select search filter', { filter })}
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
{!hideOrderSelector && (
|
||||
<SortBy
|
||||
items={sortIndexes}
|
||||
classNames={{
|
||||
select: '!select !select-bordered',
|
||||
}}
|
||||
onBlur={trackCallback('select search sort', { sort })}
|
||||
/>
|
||||
{!query && (
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={filter}
|
||||
onChange={(e) => selectFilter(e.target.value as filter)}
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
)}
|
||||
{!hideOrderSelector && !query && (
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={sort}
|
||||
onChange={(e) => selectSort(e.target.value as Sort)}
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Configure
|
||||
facetFilters={filters}
|
||||
numericFilters={numericFilters}
|
||||
// Page resets on filters change.
|
||||
page={0}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Spacer h={3} />
|
||||
|
@ -237,14 +299,14 @@ export function ContractSearch(props: {
|
|||
<PillButton
|
||||
key={'all'}
|
||||
selected={pillFilter === undefined}
|
||||
onSelect={selectFilter(undefined)}
|
||||
onSelect={selectPill(undefined)}
|
||||
>
|
||||
All
|
||||
</PillButton>
|
||||
<PillButton
|
||||
key={'personal'}
|
||||
selected={pillFilter === 'personal'}
|
||||
onSelect={selectFilter('personal')}
|
||||
onSelect={selectPill('personal')}
|
||||
>
|
||||
{user ? 'For you' : 'Featured'}
|
||||
</PillButton>
|
||||
|
@ -253,7 +315,7 @@ export function ContractSearch(props: {
|
|||
<PillButton
|
||||
key={'your-bets'}
|
||||
selected={pillFilter === 'your-bets'}
|
||||
onSelect={selectFilter('your-bets')}
|
||||
onSelect={selectPill('your-bets')}
|
||||
>
|
||||
Your bets
|
||||
</PillButton>
|
||||
|
@ -264,7 +326,7 @@ export function ContractSearch(props: {
|
|||
<PillButton
|
||||
key={slug}
|
||||
selected={pillFilter === slug}
|
||||
onSelect={selectFilter(slug)}
|
||||
onSelect={selectPill(slug)}
|
||||
>
|
||||
{name}
|
||||
</PillButton>
|
||||
|
@ -280,103 +342,17 @@ export function ContractSearch(props: {
|
|||
memberGroupSlugs.length === 0 ? (
|
||||
<>You're not following anyone, nor in any of your own groups yet.</>
|
||||
) : (
|
||||
<ContractSearchInner
|
||||
querySortOptions={querySortOptions}
|
||||
<ContractsGrid
|
||||
contracts={hitsByPage[0] === undefined ? undefined : contracts}
|
||||
loadMore={loadMore}
|
||||
hasMore={true}
|
||||
showTime={showTime}
|
||||
onContractClick={onContractClick}
|
||||
overrideGridClassName={overrideGridClassName}
|
||||
excludeContractIds={additionalFilter?.excludeContractIds}
|
||||
highlightOptions={highlightOptions}
|
||||
cardHideOptions={cardHideOptions}
|
||||
/>
|
||||
)}
|
||||
</InstantSearch>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContractSearchInner(props: {
|
||||
querySortOptions?: {
|
||||
defaultSort: Sort
|
||||
shouldLoadFromStorage?: boolean
|
||||
}
|
||||
onContractClick?: (contract: Contract) => void
|
||||
overrideGridClassName?: string
|
||||
hideQuickBet?: boolean
|
||||
excludeContractIds?: string[]
|
||||
highlightOptions?: ContractHighlightOptions
|
||||
cardHideOptions?: {
|
||||
hideQuickBet?: boolean
|
||||
hideGroupLink?: boolean
|
||||
}
|
||||
}) {
|
||||
const {
|
||||
querySortOptions,
|
||||
onContractClick,
|
||||
overrideGridClassName,
|
||||
cardHideOptions,
|
||||
excludeContractIds,
|
||||
highlightOptions,
|
||||
} = props
|
||||
const { initialQuery } = useInitialQueryAndSort(querySortOptions)
|
||||
|
||||
const { query, setQuery, setSort } = useUpdateQueryAndSort({
|
||||
shouldLoadFromStorage: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(initialQuery)
|
||||
}, [initialQuery])
|
||||
|
||||
const { currentRefinement: index } = useSortBy({
|
||||
items: [],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(query)
|
||||
}, [query])
|
||||
|
||||
const isFirstRender = useRef(true)
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const sort = index.split('contracts-')[1] as Sort
|
||||
if (sort) {
|
||||
setSort(sort)
|
||||
}
|
||||
}, [index])
|
||||
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setIsInitialLoad(false), 1000)
|
||||
return () => clearTimeout(id)
|
||||
}, [])
|
||||
|
||||
const { showMore, hits, isLastPage } = useInfiniteHits()
|
||||
let contracts = hits as any as Contract[]
|
||||
|
||||
if (isInitialLoad && contracts.length === 0) return <></>
|
||||
|
||||
const showTime = index.endsWith('close-date')
|
||||
? 'close-date'
|
||||
: index.endsWith('resolve-date')
|
||||
? 'resolve-date'
|
||||
: undefined
|
||||
|
||||
if (excludeContractIds)
|
||||
contracts = contracts.filter((c) => !excludeContractIds.includes(c.id))
|
||||
|
||||
return (
|
||||
<ContractsGrid
|
||||
contracts={contracts}
|
||||
loadMore={showMore}
|
||||
hasMore={!isLastPage}
|
||||
showTime={showTime}
|
||||
onContractClick={onContractClick}
|
||||
overrideGridClassName={overrideGridClassName}
|
||||
highlightOptions={highlightOptions}
|
||||
cardHideOptions={cardHideOptions}
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
44
web/components/contract/contract-card-preview.tsx
Normal file
44
web/components/contract/contract-card-preview.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { Contract } from 'common/contract'
|
||||
import { getBinaryProbPercent } from 'web/lib/firebase/contracts'
|
||||
import { richTextToString } from 'common/util/parse'
|
||||
import { contractTextDetails } from 'web/components/contract/contract-details'
|
||||
import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
||||
import { getProbability } from 'common/calculate'
|
||||
|
||||
export const getOpenGraphProps = (contract: Contract) => {
|
||||
const {
|
||||
resolution,
|
||||
question,
|
||||
creatorName,
|
||||
creatorUsername,
|
||||
outcomeType,
|
||||
creatorAvatarUrl,
|
||||
description: desc,
|
||||
} = contract
|
||||
const probPercent =
|
||||
outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined
|
||||
|
||||
const numericValue =
|
||||
outcomeType === 'PSEUDO_NUMERIC'
|
||||
? getFormattedMappedValue(contract)(getProbability(contract))
|
||||
: undefined
|
||||
|
||||
const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc)
|
||||
|
||||
const description = resolution
|
||||
? `Resolved ${resolution}. ${stringDesc}`
|
||||
: probPercent
|
||||
? `${probPercent} chance. ${stringDesc}`
|
||||
: stringDesc
|
||||
|
||||
return {
|
||||
question,
|
||||
probability: probPercent,
|
||||
metadata: contractTextDetails(contract),
|
||||
creatorName,
|
||||
creatorUsername,
|
||||
creatorAvatarUrl,
|
||||
description,
|
||||
numericValue,
|
||||
}
|
||||
}
|
|
@ -30,7 +30,7 @@ import { useContractWithPreload } from 'web/hooks/use-contract'
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||
import { getMappedValue } from 'common/pseudo-numeric'
|
||||
|
||||
export function ContractCard(props: {
|
||||
contract: Contract
|
||||
|
@ -115,7 +115,8 @@ export function ContractCard(props: {
|
|||
{question}
|
||||
</p>
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' &&
|
||||
{(outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
(resolution ? (
|
||||
<FreeResponseOutcomeLabel
|
||||
contract={contract}
|
||||
|
@ -158,7 +159,8 @@ export function ContractCard(props: {
|
|||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
{(outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE') && (
|
||||
<FreeResponseResolutionOrChance
|
||||
className="self-end text-gray-600"
|
||||
contract={contract}
|
||||
|
@ -210,7 +212,7 @@ export function BinaryResolutionOrChance(props: {
|
|||
}
|
||||
|
||||
function FreeResponseTopAnswer(props: {
|
||||
contract: FreeResponseContract
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
truncate: 'short' | 'long' | 'none'
|
||||
className?: string
|
||||
}) {
|
||||
|
@ -315,6 +317,12 @@ export function PseudoNumericResolutionOrExpectation(props: {
|
|||
const { resolution, resolutionValue, resolutionProbability } = contract
|
||||
const textColor = `text-blue-400`
|
||||
|
||||
const value = resolution
|
||||
? resolutionValue
|
||||
? resolutionValue
|
||||
: getMappedValue(contract)(resolutionProbability ?? 0)
|
||||
: getMappedValue(contract)(getProbability(contract))
|
||||
|
||||
return (
|
||||
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
|
||||
{resolution ? (
|
||||
|
@ -324,20 +332,21 @@ export function PseudoNumericResolutionOrExpectation(props: {
|
|||
{resolution === 'CANCEL' ? (
|
||||
<CancelLabel />
|
||||
) : (
|
||||
<div className="text-blue-400">
|
||||
{resolutionValue
|
||||
? formatLargeNumber(resolutionValue)
|
||||
: formatNumericProbability(
|
||||
resolutionProbability ?? 0,
|
||||
contract
|
||||
)}
|
||||
<div
|
||||
className={clsx('tooltip', textColor)}
|
||||
data-tip={value.toFixed(2)}
|
||||
>
|
||||
{formatLargeNumber(value)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={clsx('text-3xl', textColor)}>
|
||||
{formatNumericProbability(getProbability(contract), contract)}
|
||||
<div
|
||||
className={clsx('tooltip text-3xl', textColor)}
|
||||
data-tip={value.toFixed(2)}
|
||||
>
|
||||
{formatLargeNumber(value)}
|
||||
</div>
|
||||
<div className={clsx('text-base', textColor)}>expected</div>
|
||||
</>
|
||||
|
|
|
@ -13,6 +13,7 @@ import { TextEditor, useTextEditor } from 'web/components/editor'
|
|||
import { Button } from '../button'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { Editor, Content as ContentType } from '@tiptap/react'
|
||||
import { appendToEditor } from '../editor/utils'
|
||||
|
||||
export function ContractDescription(props: {
|
||||
contract: Contract
|
||||
|
@ -94,12 +95,7 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {
|
|||
size="xs"
|
||||
onClick={() => {
|
||||
setEditing(true)
|
||||
editor
|
||||
?.chain()
|
||||
.setContent(contract.description)
|
||||
.focus('end')
|
||||
.insertContent(`<p>${editTimestamp()}</p>`)
|
||||
.run()
|
||||
appendToEditor(editor, `<p>${editTimestamp()}</p>`)
|
||||
}}
|
||||
>
|
||||
Edit description
|
||||
|
@ -131,7 +127,7 @@ function EditQuestion(props: {
|
|||
|
||||
function joinContent(oldContent: ContentType, newContent: string) {
|
||||
const editor = new Editor({ content: oldContent, extensions: exhibitExts })
|
||||
editor.chain().focus('end').insertContent(newContent).run()
|
||||
appendToEditor(editor, newContent)
|
||||
return editor.getJSON()
|
||||
}
|
||||
|
||||
|
|
|
@ -5,13 +5,13 @@ import {
|
|||
TrendingUpIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
|
||||
import { Row } from '../layout/row'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { UserLink } from '../user-page'
|
||||
import {
|
||||
Contract,
|
||||
contractMetrics,
|
||||
contractPath,
|
||||
updateContract,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import dayjs from 'dayjs'
|
||||
|
@ -24,17 +24,16 @@ import { Bet } from 'common/bet'
|
|||
import NewContractBadge from '../new-contract-badge'
|
||||
import { UserFollowButton } from '../follow-button'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import { exhibitExts } from 'common/util/parse'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { Button } from 'web/components/button'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { appendToEditor } from '../editor/utils'
|
||||
|
||||
export type ShowTime = 'resolve-date' | 'close-date'
|
||||
|
||||
|
@ -147,6 +146,15 @@ export function ContractDetails(props: {
|
|||
const user = useUser()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const groupInfo = (
|
||||
<Row>
|
||||
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
|
||||
<span className={'line-clamp-1'}>
|
||||
{groupToDisplay ? groupToDisplay.name : 'No group'}
|
||||
</span>
|
||||
</Row>
|
||||
)
|
||||
|
||||
return (
|
||||
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
||||
<Row className="items-center gap-2">
|
||||
|
@ -168,19 +176,18 @@ export function ContractDetails(props: {
|
|||
{!disabled && <UserFollowButton userId={creatorId} small />}
|
||||
</Row>
|
||||
<Row>
|
||||
<Button
|
||||
size={'xs'}
|
||||
className={'max-w-[200px]'}
|
||||
color={'gray-white'}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Row>
|
||||
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
|
||||
<span className={'line-clamp-1'}>
|
||||
{groupToDisplay ? groupToDisplay.name : 'No group'}
|
||||
</span>
|
||||
</Row>
|
||||
</Button>
|
||||
{disabled ? (
|
||||
groupInfo
|
||||
) : (
|
||||
<Button
|
||||
size={'xs'}
|
||||
className={'max-w-[200px]'}
|
||||
color={'gray-white'}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{groupInfo}
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
<Modal open={open} setOpen={setOpen} size={'md'}>
|
||||
<Col
|
||||
|
@ -228,14 +235,6 @@ export function ContractDetails(props: {
|
|||
|
||||
<div className="whitespace-nowrap">{volumeLabel}</div>
|
||||
</Row>
|
||||
<ShareIconButton
|
||||
copyPayload={`https://${ENV_CONFIG.domain}${contractPath(contract)}${
|
||||
user?.username && contract.creatorUsername !== user?.username
|
||||
? '?referrer=' + user?.username
|
||||
: ''
|
||||
}`}
|
||||
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
|
||||
/>
|
||||
|
||||
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
||||
</Row>
|
||||
|
@ -284,12 +283,10 @@ function EditableCloseDate(props: {
|
|||
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
|
||||
|
||||
const editor = new Editor({ content, extensions: exhibitExts })
|
||||
editor
|
||||
.chain()
|
||||
.focus('end')
|
||||
.insertContent('<br /><br />')
|
||||
.insertContent(`Close date updated to ${formattedCloseDate}`)
|
||||
.run()
|
||||
appendToEditor(
|
||||
editor,
|
||||
`<br><p>Close date updated to ${formattedCloseDate}</p>`
|
||||
)
|
||||
|
||||
updateContract(contract.id, {
|
||||
closeTime: newCloseTime,
|
||||
|
|
|
@ -7,16 +7,12 @@ import { Bet } from 'common/bet'
|
|||
|
||||
import { Contract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { contractPath, contractPool } from 'web/lib/firebase/contracts'
|
||||
import { contractPool } from 'web/lib/firebase/contracts'
|
||||
import { LiquidityPanel } from '../liquidity-panel'
|
||||
import { Col } from '../layout/col'
|
||||
import { Modal } from '../layout/modal'
|
||||
import { Row } from '../layout/row'
|
||||
import { ShareEmbedButton } from '../share-embed-button'
|
||||
import { Title } from '../title'
|
||||
import { TweetButton } from '../tweet-button'
|
||||
import { InfoTooltip } from '../info-tooltip'
|
||||
import { DuplicateContractButton } from '../copy-contract-button'
|
||||
|
||||
export const contractDetailsButtonClassName =
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
||||
|
@ -61,20 +57,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
<Col className="gap-4 rounded bg-white p-6">
|
||||
<Title className="!mt-0 !mb-0" text="Market info" />
|
||||
|
||||
<div>Share</div>
|
||||
|
||||
<Row className="justify-start gap-4">
|
||||
<TweetButton
|
||||
className="self-start"
|
||||
tweetText={getTweetText(contract)}
|
||||
/>
|
||||
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
|
||||
<DuplicateContractButton contract={contract} />
|
||||
</Row>
|
||||
<div />
|
||||
|
||||
<div>Stats</div>
|
||||
|
||||
<table className="table-compact table-zebra table w-full text-gray-500">
|
||||
<tbody>
|
||||
<tr>
|
||||
|
@ -150,14 +132,3 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const getTweetText = (contract: Contract) => {
|
||||
const { question, resolution } = contract
|
||||
|
||||
const tweetDescription = resolution ? `\n\nResolved ${resolution}!` : ''
|
||||
|
||||
const timeParam = `${Date.now()}`.substring(7)
|
||||
const url = `https://manifold.markets${contractPath(contract)}?t=${timeParam}`
|
||||
|
||||
return `${question}\n\n${url}${tweetDescription}`
|
||||
}
|
||||
|
|
|
@ -107,7 +107,6 @@ export function ContractTopTrades(props: {
|
|||
comment={commentsById[topCommentId]}
|
||||
tips={tips[topCommentId]}
|
||||
betsBySameUser={[betsById[topCommentId]]}
|
||||
truncate={false}
|
||||
smallAvatar={false}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import React from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
||||
import { Col } from '../layout/col'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
|
@ -5,11 +8,9 @@ import { ContractProbGraph } from './contract-prob-graph'
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { Row } from '../layout/row'
|
||||
import { Linkify } from '../linkify'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import {
|
||||
FreeResponseResolutionOrChance,
|
||||
BinaryResolutionOrChance,
|
||||
FreeResponseResolutionOrChance,
|
||||
NumericResolutionOrExpectation,
|
||||
PseudoNumericResolutionOrExpectation,
|
||||
} from './contract-card'
|
||||
|
@ -19,8 +20,8 @@ import { AnswersGraph } from '../answers/answers-graph'
|
|||
import { Contract, CPMMBinaryContract } from 'common/contract'
|
||||
import { ContractDescription } from './contract-description'
|
||||
import { ContractDetails } from './contract-details'
|
||||
import { ShareMarket } from '../share-market'
|
||||
import { NumericGraph } from './numeric-graph'
|
||||
import { ShareRow } from './share-row'
|
||||
|
||||
export const ContractOverview = (props: {
|
||||
contract: Contract
|
||||
|
@ -32,6 +33,7 @@ export const ContractOverview = (props: {
|
|||
|
||||
const user = useUser()
|
||||
const isCreator = user?.id === creatorId
|
||||
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
|
@ -116,8 +118,7 @@ export const ContractOverview = (props: {
|
|||
<AnswersGraph contract={contract} bets={bets} />
|
||||
)}
|
||||
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}
|
||||
{(contract.description || isCreator) && <Spacer h={6} />}
|
||||
{isCreator && <ShareMarket className="px-2" contract={contract} />}
|
||||
<ShareRow user={user} contract={contract} />
|
||||
<ContractDescription
|
||||
className="px-2"
|
||||
contract={contract}
|
||||
|
|
|
@ -23,6 +23,9 @@ export function ContractTabs(props: {
|
|||
const { outcomeType } = contract
|
||||
|
||||
const userBets = user && bets.filter((bet) => bet.userId === user.id)
|
||||
const visibleBets = bets.filter(
|
||||
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
|
||||
)
|
||||
|
||||
// Load comments here, so the badge count will be correct
|
||||
const updatedComments = useComments(contract.id)
|
||||
|
@ -99,7 +102,7 @@ export function ContractTabs(props: {
|
|||
content: commentActivity,
|
||||
badge: `${comments.length}`,
|
||||
},
|
||||
{ title: 'Bets', content: betActivity, badge: `${bets.length}` },
|
||||
{ title: 'Bets', content: betActivity, badge: `${visibleBets.length}` },
|
||||
...(!user || !userBets?.length
|
||||
? []
|
||||
: [{ title: 'Your bets', content: yourTrades }]),
|
||||
|
|
|
@ -8,6 +8,7 @@ import { ContractSearch } from '../contract-search'
|
|||
import { useIsVisible } from 'web/hooks/use-is-visible'
|
||||
import { useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { LoadingIndicator } from '../loading-indicator'
|
||||
|
||||
export type ContractHighlightOptions = {
|
||||
contractIds?: string[]
|
||||
|
@ -15,7 +16,7 @@ export type ContractHighlightOptions = {
|
|||
}
|
||||
|
||||
export function ContractsGrid(props: {
|
||||
contracts: Contract[]
|
||||
contracts: Contract[] | undefined
|
||||
loadMore: () => void
|
||||
hasMore: boolean
|
||||
showTime?: ShowTime
|
||||
|
@ -49,6 +50,10 @@ export function ContractsGrid(props: {
|
|||
}
|
||||
}, [isBottomVisible, hasMore, loadMore])
|
||||
|
||||
if (contracts === undefined) {
|
||||
return <LoadingIndicator />
|
||||
}
|
||||
|
||||
if (contracts.length === 0) {
|
||||
return (
|
||||
<p className="mx-2 text-gray-500">
|
77
web/components/contract/share-modal.tsx
Normal file
77
web/components/contract/share-modal.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { LinkIcon } from '@heroicons/react/outline'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { Contract } from 'common/contract'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
import { Col } from '../layout/col'
|
||||
import { Modal } from '../layout/modal'
|
||||
import { Row } from '../layout/row'
|
||||
import { ShareEmbedButton } from '../share-embed-button'
|
||||
import { Title } from '../title'
|
||||
import { TweetButton } from '../tweet-button'
|
||||
import { DuplicateContractButton } from '../copy-contract-button'
|
||||
import { Button } from '../button'
|
||||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { User } from 'common/user'
|
||||
|
||||
export function ShareModal(props: {
|
||||
contract: Contract
|
||||
user: User | undefined | null
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}) {
|
||||
const { contract, user, isOpen, setOpen } = props
|
||||
|
||||
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
|
||||
|
||||
const copyPayload = `https://${ENV_CONFIG.domain}${contractPath(contract)}${
|
||||
user?.username && contract.creatorUsername !== user?.username
|
||||
? '?referrer=' + user?.username
|
||||
: ''
|
||||
}`
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="gap-4 rounded bg-white p-4">
|
||||
<Title className="!mt-0 mb-2" text="Share this market" />
|
||||
|
||||
<Button
|
||||
size="2xl"
|
||||
color="gradient"
|
||||
className={'mb-2 flex max-w-xs self-center'}
|
||||
onClick={() => {
|
||||
copyToClipboard(copyPayload)
|
||||
track('copy share link')
|
||||
toast.success('Link copied!', {
|
||||
icon: linkIcon,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{linkIcon} Copy link
|
||||
</Button>
|
||||
|
||||
<Row className="justify-start gap-4 self-center">
|
||||
<TweetButton
|
||||
className="self-start"
|
||||
tweetText={getTweetText(contract)}
|
||||
/>
|
||||
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
|
||||
<DuplicateContractButton contract={contract} />
|
||||
</Row>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const getTweetText = (contract: Contract) => {
|
||||
const { question, resolution } = contract
|
||||
|
||||
const tweetDescription = resolution ? `\n\nResolved ${resolution}!` : ''
|
||||
|
||||
const timeParam = `${Date.now()}`.substring(7)
|
||||
const url = `https://manifold.markets${contractPath(contract)}?t=${timeParam}`
|
||||
|
||||
return `${question}\n\n${url}${tweetDescription}`
|
||||
}
|
67
web/components/contract/share-row.tsx
Normal file
67
web/components/contract/share-row.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import clsx from 'clsx'
|
||||
import { ShareIcon } from '@heroicons/react/outline'
|
||||
|
||||
import { Row } from '../layout/row'
|
||||
import { Contract } from 'web/lib/firebase/contracts'
|
||||
import { useState } from 'react'
|
||||
import { Button } from 'web/components/button'
|
||||
import { CreateChallengeModal } from '../challenges/create-challenge-modal'
|
||||
import { User } from 'common/user'
|
||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||
import { ShareModal } from './share-modal'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
|
||||
export function ShareRow(props: {
|
||||
contract: Contract
|
||||
user: User | undefined | null
|
||||
}) {
|
||||
const { user, contract } = props
|
||||
const { outcomeType, resolution } = contract
|
||||
|
||||
const showChallenge =
|
||||
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isShareOpen, setShareOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Row className="mt-2">
|
||||
<Button
|
||||
size="lg"
|
||||
color="gray-white"
|
||||
className={'flex'}
|
||||
onClick={() => {
|
||||
setShareOpen(true)
|
||||
}}
|
||||
>
|
||||
<ShareIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />
|
||||
Share
|
||||
<ShareModal
|
||||
isOpen={isShareOpen}
|
||||
setOpen={setShareOpen}
|
||||
contract={contract}
|
||||
user={user}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{showChallenge && (
|
||||
<Button
|
||||
size="lg"
|
||||
color="gray-white"
|
||||
onClick={withTracking(
|
||||
() => setIsOpen(true),
|
||||
'click challenge button'
|
||||
)}
|
||||
>
|
||||
⚔️ Challenge
|
||||
<CreateChallengeModal
|
||||
isOpen={isOpen}
|
||||
setOpen={setIsOpen}
|
||||
user={user}
|
||||
contract={contract}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
|
@ -2,7 +2,6 @@ import React, { Fragment } from 'react'
|
|||
import { LinkIcon } from '@heroicons/react/outline'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { ToastClipboard } from 'web/components/toast-clipboard'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
@ -14,6 +13,8 @@ export function CopyLinkButton(props: {
|
|||
tracking?: string
|
||||
buttonClassName?: string
|
||||
toastClassName?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
label?: string
|
||||
}) {
|
||||
const { url, displayUrl, tracking, buttonClassName, toastClassName } = props
|
||||
|
||||
|
|
|
@ -46,14 +46,16 @@ export function useTextEditor(props: {
|
|||
max?: number
|
||||
defaultValue?: Content
|
||||
disabled?: boolean
|
||||
simple?: boolean
|
||||
}) {
|
||||
const { placeholder, max, defaultValue = '', disabled } = props
|
||||
const { placeholder, max, defaultValue = '', disabled, simple } = props
|
||||
|
||||
const users = useUsers()
|
||||
|
||||
const editorClass = clsx(
|
||||
proseClass,
|
||||
'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0'
|
||||
!simple && 'min-h-[6em]',
|
||||
'outline-none pt-2 px-4'
|
||||
)
|
||||
|
||||
const editor = useEditor(
|
||||
|
@ -61,7 +63,8 @@ export function useTextEditor(props: {
|
|||
editorProps: { attributes: { class: editorClass } },
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
heading: simple ? false : { levels: [1, 2, 3] },
|
||||
horizontalRule: simple ? false : {},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
|
@ -125,8 +128,9 @@ function isValidIframe(text: string) {
|
|||
export function TextEditor(props: {
|
||||
editor: Editor | null
|
||||
upload: ReturnType<typeof useUploadMutation>
|
||||
children?: React.ReactNode // additional toolbar buttons
|
||||
}) {
|
||||
const { editor, upload } = props
|
||||
const { editor, upload, children } = props
|
||||
const [iframeOpen, setIframeOpen] = useState(false)
|
||||
const [marketOpen, setMarketOpen] = useState(false)
|
||||
|
||||
|
@ -139,30 +143,13 @@ export function TextEditor(props: {
|
|||
editor={editor}
|
||||
className={clsx(proseClass, '-ml-2 mr-2 w-full text-slate-300 ')}
|
||||
>
|
||||
Type <em>*markdown*</em>. Paste or{' '}
|
||||
<FileUploadButton
|
||||
className="link text-blue-300"
|
||||
onFiles={upload.mutate}
|
||||
>
|
||||
upload
|
||||
</FileUploadButton>{' '}
|
||||
images!
|
||||
Type <em>*markdown*</em>
|
||||
</FloatingMenu>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
|
||||
<div className="rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
|
||||
<EditorContent editor={editor} />
|
||||
{/* Spacer element to match the height of the toolbar */}
|
||||
<div className="py-2" aria-hidden="true">
|
||||
{/* Matches height of button in toolbar (1px border + 36px content height) */}
|
||||
<div className="py-px">
|
||||
<div className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar, with buttons for image and embeds */}
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2">
|
||||
<div className="flex items-center space-x-5">
|
||||
{/* Toolbar, with buttons for images and embeds */}
|
||||
<div className="flex h-9 items-center gap-5 pl-4 pr-1">
|
||||
<div className="tooltip flex items-center" data-tip="Add image">
|
||||
<FileUploadButton
|
||||
onFiles={upload.mutate}
|
||||
|
@ -202,6 +189,8 @@ export function TextEditor(props: {
|
|||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ml-auto" />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -11,7 +11,7 @@ const name = 'mention-component'
|
|||
|
||||
const MentionComponent = (props: any) => {
|
||||
return (
|
||||
<NodeViewWrapper className={clsx(name, 'not-prose inline text-indigo-700')}>
|
||||
<NodeViewWrapper className={clsx(name, 'not-prose text-indigo-700')}>
|
||||
<Linkify text={'@' + props.node.attrs.label} />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
|
@ -25,5 +25,6 @@ const MentionComponent = (props: any) => {
|
|||
export const DisplayMention = Mention.extend({
|
||||
parseHTML: () => [{ tag: name }],
|
||||
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
||||
addNodeView: () => ReactNodeViewRenderer(MentionComponent),
|
||||
addNodeView: () =>
|
||||
ReactNodeViewRenderer(MentionComponent, { className: 'inline-block' }),
|
||||
})
|
||||
|
|
10
web/components/editor/utils.ts
Normal file
10
web/components/editor/utils.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Editor, Content } from '@tiptap/react'
|
||||
|
||||
export function appendToEditor(editor: Editor | null, content: Content) {
|
||||
editor
|
||||
?.chain()
|
||||
.focus('end')
|
||||
.createParagraphNear()
|
||||
.insertContent(content)
|
||||
.run()
|
||||
}
|
|
@ -26,7 +26,10 @@ export function ContractActivity(props: {
|
|||
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
const comments = props.comments
|
||||
const updatedBets = useBets(contract.id)
|
||||
const updatedBets = useBets(contract.id, {
|
||||
filterChallenges: false,
|
||||
filterRedemptions: true,
|
||||
})
|
||||
const bets = (updatedBets ?? props.bets).filter(
|
||||
(bet) => !bet.isRedemption && bet.amount !== 0
|
||||
)
|
||||
|
|
|
@ -31,9 +31,9 @@ export function FeedAnswerCommentGroup(props: {
|
|||
const { answer, contract, comments, tips, bets, user } = props
|
||||
const { username, avatarUrl, name, text } = answer
|
||||
|
||||
const [replyToUsername, setReplyToUsername] = useState('')
|
||||
const [replyToUser, setReplyToUser] =
|
||||
useState<Pick<User, 'id' | 'username'>>()
|
||||
const [showReply, setShowReply] = useState(false)
|
||||
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
|
||||
const [highlighted, setHighlighted] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
|
@ -70,9 +70,14 @@ export function FeedAnswerCommentGroup(props: {
|
|||
|
||||
const scrollAndOpenReplyInput = useEvent(
|
||||
(comment?: Comment, answer?: Answer) => {
|
||||
setReplyToUsername(comment?.userUsername ?? answer?.username ?? '')
|
||||
setReplyToUser(
|
||||
comment
|
||||
? { id: comment.userId, username: comment.userUsername }
|
||||
: answer
|
||||
? { id: answer.userId, username: answer.username }
|
||||
: undefined
|
||||
)
|
||||
setShowReply(true)
|
||||
inputRef?.focus()
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -80,7 +85,7 @@ export function FeedAnswerCommentGroup(props: {
|
|||
// Only show one comment input for a bet at a time
|
||||
if (
|
||||
betsByCurrentUser.length > 1 &&
|
||||
inputRef?.textContent?.length === 0 &&
|
||||
// inputRef?.textContent?.length === 0 && //TODO: editor.isEmpty
|
||||
betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0]
|
||||
?.outcome !== answer.number.toString()
|
||||
)
|
||||
|
@ -89,10 +94,6 @@ export function FeedAnswerCommentGroup(props: {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [betsByCurrentUser.length, user, answer.number])
|
||||
|
||||
useEffect(() => {
|
||||
if (showReply && inputRef) inputRef.focus()
|
||||
}, [inputRef, showReply])
|
||||
|
||||
useEffect(() => {
|
||||
if (router.asPath.endsWith(`#${answerElementId}`)) {
|
||||
setHighlighted(true)
|
||||
|
@ -154,7 +155,6 @@ export function FeedAnswerCommentGroup(props: {
|
|||
commentsList={commentsList}
|
||||
betsByUserId={betsByUserId}
|
||||
smallAvatar={true}
|
||||
truncate={false}
|
||||
bets={bets}
|
||||
tips={tips}
|
||||
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
|
||||
|
@ -172,12 +172,8 @@ export function FeedAnswerCommentGroup(props: {
|
|||
betsByCurrentUser={betsByCurrentUser}
|
||||
commentsByCurrentUser={commentsByCurrentUser}
|
||||
parentAnswerOutcome={answer.number.toString()}
|
||||
replyToUsername={replyToUsername}
|
||||
setRef={setInputRef}
|
||||
onSubmitComment={() => {
|
||||
setShowReply(false)
|
||||
setReplyToUsername('')
|
||||
}}
|
||||
replyToUser={replyToUser}
|
||||
onSubmitComment={() => setShowReply(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -10,11 +10,14 @@ import { UsersIcon } from '@heroicons/react/solid'
|
|||
import { formatMoney, formatPercent } from 'common/util/format'
|
||||
import { OutcomeLabel } from 'web/components/outcome-label'
|
||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
||||
import React, { Fragment } from 'react'
|
||||
import React, { Fragment, useEffect } from 'react'
|
||||
import { uniqBy, partition, sumBy, groupBy } from 'lodash'
|
||||
import { JoinSpans } from 'web/components/join-spans'
|
||||
import { UserLink } from '../user-page'
|
||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
|
||||
import { Challenge } from 'common/challenge'
|
||||
|
||||
export function FeedBet(props: {
|
||||
contract: Contract
|
||||
|
@ -79,7 +82,15 @@ export function BetStatusText(props: {
|
|||
const { outcomeType } = contract
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
const isFreeResponse = outcomeType === 'FREE_RESPONSE'
|
||||
const { amount, outcome, createdTime } = bet
|
||||
const { amount, outcome, createdTime, challengeSlug } = bet
|
||||
const [challenge, setChallenge] = React.useState<Challenge>()
|
||||
useEffect(() => {
|
||||
if (challengeSlug) {
|
||||
getChallenge(challengeSlug, contract.id).then((c) => {
|
||||
setChallenge(c)
|
||||
})
|
||||
}
|
||||
}, [challengeSlug, contract.id])
|
||||
|
||||
const bought = amount >= 0 ? 'bought' : 'sold'
|
||||
const outOfTotalAmount =
|
||||
|
@ -133,6 +144,14 @@ export function BetStatusText(props: {
|
|||
{fromProb === toProb
|
||||
? `at ${fromProb}`
|
||||
: `from ${fromProb} to ${toProb}`}
|
||||
{challengeSlug && (
|
||||
<SiteLink
|
||||
href={challenge ? getChallengeUrl(challenge) : ''}
|
||||
className={'mx-1'}
|
||||
>
|
||||
[challenge]
|
||||
</SiteLink>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
|
|
|
@ -13,25 +13,22 @@ import { Avatar } from 'web/components/avatar'
|
|||
import { UserLink } from 'web/components/user-page'
|
||||
import { OutcomeLabel } from 'web/components/outcome-label'
|
||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import {
|
||||
createCommentOnContract,
|
||||
MAX_COMMENT_LENGTH,
|
||||
} from 'web/lib/firebase/comments'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { Linkify } from 'web/components/linkify'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { BetStatusText } from 'web/components/feed/feed-bets'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { getProbability } from 'common/calculate'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { PaperAirplaneIcon } from '@heroicons/react/outline'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { useEvent } from 'web/hooks/use-event'
|
||||
import { Tipper } from '../tipper'
|
||||
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { Content, TextEditor, useTextEditor } from '../editor'
|
||||
import { Editor } from '@tiptap/react'
|
||||
|
||||
export function FeedCommentThread(props: {
|
||||
contract: Contract
|
||||
|
@ -39,20 +36,12 @@ export function FeedCommentThread(props: {
|
|||
tips: CommentTipMap
|
||||
parentComment: Comment
|
||||
bets: Bet[]
|
||||
truncate?: boolean
|
||||
smallAvatar?: boolean
|
||||
}) {
|
||||
const {
|
||||
contract,
|
||||
comments,
|
||||
bets,
|
||||
tips,
|
||||
truncate,
|
||||
smallAvatar,
|
||||
parentComment,
|
||||
} = props
|
||||
const { contract, comments, bets, tips, smallAvatar, parentComment } = props
|
||||
const [showReply, setShowReply] = useState(false)
|
||||
const [replyToUsername, setReplyToUsername] = useState('')
|
||||
const [replyToUser, setReplyToUser] =
|
||||
useState<{ id: string; username: string }>()
|
||||
const betsByUserId = groupBy(bets, (bet) => bet.userId)
|
||||
const user = useUser()
|
||||
const commentsList = comments.filter(
|
||||
|
@ -60,15 +49,12 @@ export function FeedCommentThread(props: {
|
|||
parentComment.id && comment.replyToCommentId === parentComment.id
|
||||
)
|
||||
commentsList.unshift(parentComment)
|
||||
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
|
||||
|
||||
function scrollAndOpenReplyInput(comment: Comment) {
|
||||
setReplyToUsername(comment.userUsername)
|
||||
setReplyToUser({ id: comment.userId, username: comment.userUsername })
|
||||
setShowReply(true)
|
||||
inputRef?.focus()
|
||||
}
|
||||
useEffect(() => {
|
||||
if (showReply && inputRef) inputRef.focus()
|
||||
}, [inputRef, showReply])
|
||||
|
||||
return (
|
||||
<Col className={'w-full gap-3 pr-1'}>
|
||||
<span
|
||||
|
@ -81,7 +67,6 @@ export function FeedCommentThread(props: {
|
|||
betsByUserId={betsByUserId}
|
||||
tips={tips}
|
||||
smallAvatar={smallAvatar}
|
||||
truncate={truncate}
|
||||
bets={bets}
|
||||
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
|
||||
/>
|
||||
|
@ -98,13 +83,9 @@ export function FeedCommentThread(props: {
|
|||
(c) => c.userId === user?.id
|
||||
)}
|
||||
parentCommentId={parentComment.id}
|
||||
replyToUsername={replyToUsername}
|
||||
replyToUser={replyToUser}
|
||||
parentAnswerOutcome={comments[0].answerOutcome}
|
||||
setRef={setInputRef}
|
||||
onSubmitComment={() => {
|
||||
setShowReply(false)
|
||||
setReplyToUsername('')
|
||||
}}
|
||||
onSubmitComment={() => setShowReply(false)}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
|
@ -121,14 +102,12 @@ export function CommentRepliesList(props: {
|
|||
bets: Bet[]
|
||||
treatFirstIndexEqually?: boolean
|
||||
smallAvatar?: boolean
|
||||
truncate?: boolean
|
||||
}) {
|
||||
const {
|
||||
contract,
|
||||
commentsList,
|
||||
betsByUserId,
|
||||
tips,
|
||||
truncate,
|
||||
smallAvatar,
|
||||
bets,
|
||||
scrollAndOpenReplyInput,
|
||||
|
@ -168,7 +147,6 @@ export function CommentRepliesList(props: {
|
|||
: undefined
|
||||
}
|
||||
smallAvatar={smallAvatar}
|
||||
truncate={truncate}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
@ -182,7 +160,6 @@ export function FeedComment(props: {
|
|||
tips: CommentTips
|
||||
betsBySameUser: Bet[]
|
||||
probAtCreatedTime?: number
|
||||
truncate?: boolean
|
||||
smallAvatar?: boolean
|
||||
onReplyClick?: (comment: Comment) => void
|
||||
}) {
|
||||
|
@ -192,10 +169,10 @@ export function FeedComment(props: {
|
|||
tips,
|
||||
betsBySameUser,
|
||||
probAtCreatedTime,
|
||||
truncate,
|
||||
onReplyClick,
|
||||
} = props
|
||||
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
||||
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
|
||||
comment
|
||||
let betOutcome: string | undefined,
|
||||
bought: string | undefined,
|
||||
money: string | undefined
|
||||
|
@ -276,11 +253,9 @@ export function FeedComment(props: {
|
|||
elementId={comment.id}
|
||||
/>
|
||||
</div>
|
||||
<TruncatedComment
|
||||
comment={text}
|
||||
moreHref={contractPath(contract)}
|
||||
shouldTruncate={truncate}
|
||||
/>
|
||||
<div className="mt-2 text-[15px] text-gray-700">
|
||||
<Content content={content || text} />
|
||||
</div>
|
||||
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
|
||||
<Tipper comment={comment} tips={tips ?? {}} />
|
||||
{onReplyClick && (
|
||||
|
@ -345,8 +320,7 @@ export function CommentInput(props: {
|
|||
contract: Contract
|
||||
betsByCurrentUser: Bet[]
|
||||
commentsByCurrentUser: Comment[]
|
||||
replyToUsername?: string
|
||||
setRef?: (ref: HTMLTextAreaElement) => void
|
||||
replyToUser?: { id: string; username: string }
|
||||
// Reply to a free response answer
|
||||
parentAnswerOutcome?: string
|
||||
// Reply to another comment
|
||||
|
@ -359,12 +333,18 @@ export function CommentInput(props: {
|
|||
commentsByCurrentUser,
|
||||
parentAnswerOutcome,
|
||||
parentCommentId,
|
||||
replyToUsername,
|
||||
replyToUser,
|
||||
onSubmitComment,
|
||||
setRef,
|
||||
} = props
|
||||
const user = useUser()
|
||||
const [comment, setComment] = useState('')
|
||||
const { editor, upload } = useTextEditor({
|
||||
simple: true,
|
||||
max: MAX_COMMENT_LENGTH,
|
||||
placeholder:
|
||||
!!parentCommentId || !!parentAnswerOutcome
|
||||
? 'Write a reply...'
|
||||
: 'Write a comment...',
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const mostRecentCommentableBet = getMostRecentCommentableBet(
|
||||
|
@ -380,18 +360,17 @@ export function CommentInput(props: {
|
|||
track('sign in to comment')
|
||||
return await firebaseLogin()
|
||||
}
|
||||
if (!comment || isSubmitting) return
|
||||
if (!editor || editor.isEmpty || isSubmitting) return
|
||||
setIsSubmitting(true)
|
||||
await createCommentOnContract(
|
||||
contract.id,
|
||||
comment,
|
||||
editor.getJSON(),
|
||||
user,
|
||||
betId,
|
||||
parentAnswerOutcome,
|
||||
parentCommentId
|
||||
)
|
||||
onSubmitComment?.()
|
||||
setComment('')
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
|
@ -446,14 +425,12 @@ export function CommentInput(props: {
|
|||
)}
|
||||
</div>
|
||||
<CommentInputTextArea
|
||||
commentText={comment}
|
||||
setComment={setComment}
|
||||
isReply={!!parentCommentId || !!parentAnswerOutcome}
|
||||
replyToUsername={replyToUsername ?? ''}
|
||||
editor={editor}
|
||||
upload={upload}
|
||||
replyToUser={replyToUser}
|
||||
user={user}
|
||||
submitComment={submitComment}
|
||||
isSubmitting={isSubmitting}
|
||||
setRef={setRef}
|
||||
presetId={id}
|
||||
/>
|
||||
</div>
|
||||
|
@ -465,94 +442,93 @@ export function CommentInput(props: {
|
|||
|
||||
export function CommentInputTextArea(props: {
|
||||
user: User | undefined | null
|
||||
isReply: boolean
|
||||
replyToUsername: string
|
||||
commentText: string
|
||||
setComment: (text: string) => void
|
||||
replyToUser?: { id: string; username: string }
|
||||
editor: Editor | null
|
||||
upload: Parameters<typeof TextEditor>[0]['upload']
|
||||
submitComment: (id?: string) => void
|
||||
isSubmitting: boolean
|
||||
setRef?: (ref: HTMLTextAreaElement) => void
|
||||
submitOnEnter?: boolean
|
||||
presetId?: string
|
||||
enterToSubmitOnDesktop?: boolean
|
||||
}) {
|
||||
const {
|
||||
isReply,
|
||||
setRef,
|
||||
user,
|
||||
commentText,
|
||||
setComment,
|
||||
editor,
|
||||
upload,
|
||||
submitComment,
|
||||
presetId,
|
||||
isSubmitting,
|
||||
replyToUsername,
|
||||
enterToSubmitOnDesktop,
|
||||
submitOnEnter,
|
||||
replyToUser,
|
||||
} = props
|
||||
const { width } = useWindowSize()
|
||||
const memoizedSetComment = useEvent(setComment)
|
||||
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
|
||||
|
||||
useEffect(() => {
|
||||
if (!replyToUsername || !user || replyToUsername === user.username) return
|
||||
const replacement = `@${replyToUsername} `
|
||||
memoizedSetComment(replacement + commentText.replace(replacement, ''))
|
||||
editor?.setEditable(!isSubmitting)
|
||||
}, [isSubmitting, editor])
|
||||
|
||||
const submit = () => {
|
||||
submitComment(presetId)
|
||||
editor?.commands?.clearContent()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
// submit on Enter key
|
||||
editor.setOptions({
|
||||
editorProps: {
|
||||
handleKeyDown: (view, event) => {
|
||||
if (
|
||||
submitOnEnter &&
|
||||
event.key === 'Enter' &&
|
||||
!event.shiftKey &&
|
||||
(!isMobile || event.ctrlKey || event.metaKey) &&
|
||||
// mention list is closed
|
||||
!(view.state as any).mention$.active
|
||||
) {
|
||||
submit()
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
})
|
||||
// insert at mention and focus
|
||||
if (replyToUser) {
|
||||
editor
|
||||
.chain()
|
||||
.setContent({
|
||||
type: 'mention',
|
||||
attrs: { label: replyToUser.username, id: replyToUser.id },
|
||||
})
|
||||
.insertContent(' ')
|
||||
.focus()
|
||||
.run()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user, replyToUsername, memoizedSetComment])
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row className="gap-1.5 text-gray-700">
|
||||
<Textarea
|
||||
ref={setRef}
|
||||
value={commentText}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
className={clsx('textarea textarea-bordered w-full resize-none')}
|
||||
// Make room for floating submit button.
|
||||
style={{ paddingRight: 48 }}
|
||||
placeholder={
|
||||
isReply
|
||||
? 'Write a reply... '
|
||||
: enterToSubmitOnDesktop
|
||||
? 'Send a message'
|
||||
: 'Write a comment...'
|
||||
}
|
||||
autoFocus={false}
|
||||
maxLength={MAX_COMMENT_LENGTH}
|
||||
disabled={isSubmitting}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
(enterToSubmitOnDesktop &&
|
||||
e.key === 'Enter' &&
|
||||
!e.shiftKey &&
|
||||
width &&
|
||||
width > 768) ||
|
||||
(e.key === 'Enter' && (e.ctrlKey || e.metaKey))
|
||||
) {
|
||||
e.preventDefault()
|
||||
submitComment(presetId)
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Col className={clsx('relative justify-end')}>
|
||||
<div>
|
||||
<TextEditor editor={editor} upload={upload}>
|
||||
{user && !isSubmitting && (
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost btn-sm absolute right-2 bottom-2 flex-row pl-2 capitalize',
|
||||
!commentText && 'pointer-events-none text-gray-500'
|
||||
)}
|
||||
onClick={() => {
|
||||
submitComment(presetId)
|
||||
}}
|
||||
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
|
||||
disabled={!editor || editor.isEmpty}
|
||||
onClick={submit}
|
||||
>
|
||||
<PaperAirplaneIcon
|
||||
className={'m-0 min-w-[22px] rotate-90 p-0 '}
|
||||
height={25}
|
||||
/>
|
||||
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isSubmitting && (
|
||||
<LoadingIndicator spinnerClassName={'border-gray-500'} />
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</TextEditor>
|
||||
</div>
|
||||
<Row>
|
||||
{!user && (
|
||||
<button
|
||||
|
@ -567,38 +543,6 @@ export function CommentInputTextArea(props: {
|
|||
)
|
||||
}
|
||||
|
||||
export function TruncatedComment(props: {
|
||||
comment: string
|
||||
moreHref: string
|
||||
shouldTruncate?: boolean
|
||||
}) {
|
||||
const { comment, moreHref, shouldTruncate } = props
|
||||
let truncated = comment
|
||||
|
||||
// Keep descriptions to at most 400 characters
|
||||
const MAX_CHARS = 400
|
||||
if (shouldTruncate && truncated.length > MAX_CHARS) {
|
||||
truncated = truncated.slice(0, MAX_CHARS)
|
||||
// Make sure to end on a space
|
||||
const i = truncated.lastIndexOf(' ')
|
||||
truncated = truncated.slice(0, i)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-2 whitespace-pre-line break-words text-gray-700"
|
||||
style={{ fontSize: 15 }}
|
||||
>
|
||||
<Linkify text={truncated} />
|
||||
{truncated != comment && (
|
||||
<SiteLink href={moreHref} className="text-indigo-700">
|
||||
... (show more)
|
||||
</SiteLink>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getBettorsLargestPositionBeforeTime(
|
||||
contract: Contract,
|
||||
createdTime: number,
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Button } from 'web/components/button'
|
|||
import { GroupSelector } from 'web/components/groups/group-selector'
|
||||
import {
|
||||
addContractToGroup,
|
||||
canModifyGroupContracts,
|
||||
removeContractFromGroup,
|
||||
} from 'web/lib/firebase/groups'
|
||||
import { User } from 'common/user'
|
||||
|
@ -57,11 +58,11 @@ export function ContractGroupsList(props: {
|
|||
<Row className="line-clamp-1 items-center gap-2">
|
||||
<GroupLinkItem group={group} />
|
||||
</Row>
|
||||
{user && group.memberIds.includes(user.id) && (
|
||||
{user && canModifyGroupContracts(group, user.id) && (
|
||||
<Button
|
||||
color={'gray-white'}
|
||||
size={'xs'}
|
||||
onClick={() => removeContractFromGroup(group, contract)}
|
||||
onClick={() => removeContractFromGroup(group, contract, user.id)}
|
||||
>
|
||||
<XIcon className="h-4 w-4 text-gray-500" />
|
||||
</Button>
|
||||
|
|
|
@ -1,28 +1,26 @@
|
|||
import { Row } from 'web/components/layout/row'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { User } from 'common/user'
|
||||
import { PrivateUser, User } from 'common/user'
|
||||
import React, { useEffect, memo, useState, useMemo } from 'react'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
import { Group } from 'common/group'
|
||||
import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments'
|
||||
import {
|
||||
CommentInputTextArea,
|
||||
TruncatedComment,
|
||||
} from 'web/components/feed/feed-comments'
|
||||
import { CommentInputTextArea } from 'web/components/feed/feed-comments'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
|
||||
import { useRouter } from 'next/router'
|
||||
import clsx from 'clsx'
|
||||
import { UserLink } from 'web/components/user-page'
|
||||
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
||||
import { Tipper } from 'web/components/tipper'
|
||||
import { sum } from 'lodash'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { Content, useTextEditor } from 'web/components/editor'
|
||||
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
||||
import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline'
|
||||
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
||||
|
||||
export function GroupChat(props: {
|
||||
messages: Comment[]
|
||||
|
@ -31,38 +29,48 @@ export function GroupChat(props: {
|
|||
tips: CommentTipMap
|
||||
}) {
|
||||
const { messages, user, group, tips } = props
|
||||
const [messageText, setMessageText] = useState('')
|
||||
const { editor, upload } = useTextEditor({
|
||||
simple: true,
|
||||
placeholder: 'Send a message',
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [scrollToBottomRef, setScrollToBottomRef] =
|
||||
useState<HTMLDivElement | null>(null)
|
||||
const [scrollToMessageId, setScrollToMessageId] = useState('')
|
||||
const [scrollToMessageRef, setScrollToMessageRef] =
|
||||
useState<HTMLDivElement | null>(null)
|
||||
const [replyToUsername, setReplyToUsername] = useState('')
|
||||
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
|
||||
const [groupedMessages, setGroupedMessages] = useState<Comment[]>([])
|
||||
const [replyToUser, setReplyToUser] = useState<any>()
|
||||
|
||||
const router = useRouter()
|
||||
const isMember = user && group.memberIds.includes(user?.id)
|
||||
|
||||
useMemo(() => {
|
||||
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
|
||||
|
||||
// array of groups, where each group is an array of messages that are displayed as one
|
||||
const groupedMessages = useMemo(() => {
|
||||
// Group messages with createdTime within 2 minutes of each other.
|
||||
const tempMessages = []
|
||||
const tempGrouped: Comment[][] = []
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
if (i === 0) tempMessages.push({ ...message })
|
||||
if (i === 0) tempGrouped.push([message])
|
||||
else {
|
||||
const prevMessage = messages[i - 1]
|
||||
const diff = message.createdTime - prevMessage.createdTime
|
||||
const creatorsMatch = message.userId === prevMessage.userId
|
||||
if (diff < 2 * 60 * 1000 && creatorsMatch) {
|
||||
tempMessages[tempMessages.length - 1].text += `\n${message.text}`
|
||||
tempGrouped.at(-1)?.push(message)
|
||||
} else {
|
||||
tempMessages.push({ ...message })
|
||||
tempGrouped.push([message])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setGroupedMessages(tempMessages)
|
||||
return tempGrouped
|
||||
}, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -70,9 +78,10 @@ export function GroupChat(props: {
|
|||
}, [scrollToMessageRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSubmitting)
|
||||
scrollToBottomRef?.scrollTo({ top: scrollToBottomRef?.scrollHeight || 0 })
|
||||
}, [scrollToBottomRef, isSubmitting])
|
||||
if (scrollToBottomRef)
|
||||
scrollToBottomRef.scrollTo({ top: scrollToBottomRef.scrollHeight || 0 })
|
||||
// Must also listen to groupedMessages as they update the height of the messaging window
|
||||
}, [scrollToBottomRef, groupedMessages])
|
||||
|
||||
useEffect(() => {
|
||||
const elementInUrl = router.asPath.split('#')[1]
|
||||
|
@ -81,8 +90,14 @@ export function GroupChat(props: {
|
|||
}
|
||||
}, [messages, router.asPath])
|
||||
|
||||
useEffect(() => {
|
||||
// is mobile?
|
||||
if (width && width > 720) focusInput()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [width])
|
||||
|
||||
function onReplyClick(comment: Comment) {
|
||||
setReplyToUsername(comment.userUsername)
|
||||
setReplyToUser({ id: comment.userId, username: comment.userUsername })
|
||||
}
|
||||
|
||||
async function submitMessage() {
|
||||
|
@ -90,27 +105,18 @@ export function GroupChat(props: {
|
|||
track('sign in to comment')
|
||||
return await firebaseLogin()
|
||||
}
|
||||
if (!messageText || isSubmitting) return
|
||||
if (!editor || editor.isEmpty || isSubmitting) return
|
||||
setIsSubmitting(true)
|
||||
await createCommentOnGroup(group.id, messageText, user)
|
||||
setMessageText('')
|
||||
await createCommentOnGroup(group.id, editor.getJSON(), user)
|
||||
editor.commands.clearContent()
|
||||
setIsSubmitting(false)
|
||||
setReplyToUsername('')
|
||||
inputRef?.focus()
|
||||
setReplyToUser(undefined)
|
||||
focusInput()
|
||||
}
|
||||
function focusInput() {
|
||||
inputRef?.focus()
|
||||
editor?.commands.focus()
|
||||
}
|
||||
|
||||
const { width, height } = useWindowSize()
|
||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||
// Subtract bottom bar when it's showing (less than lg screen)
|
||||
const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0
|
||||
const remainingHeight =
|
||||
(height ?? window.innerHeight) -
|
||||
(containerRef?.offsetTop ?? 0) -
|
||||
bottomBarHeight
|
||||
|
||||
return (
|
||||
<Col ref={setContainerRef} style={{ height: remainingHeight }}>
|
||||
<Col
|
||||
|
@ -119,20 +125,20 @@ export function GroupChat(props: {
|
|||
}
|
||||
ref={setScrollToBottomRef}
|
||||
>
|
||||
{groupedMessages.map((message) => (
|
||||
{groupedMessages.map((messages) => (
|
||||
<GroupMessage
|
||||
user={user}
|
||||
key={message.id}
|
||||
comment={message}
|
||||
key={`group ${messages[0].id}`}
|
||||
comments={messages}
|
||||
group={group}
|
||||
onReplyClick={onReplyClick}
|
||||
highlight={message.id === scrollToMessageId}
|
||||
highlight={messages[0].id === scrollToMessageId}
|
||||
setRef={
|
||||
scrollToMessageId === message.id
|
||||
scrollToMessageId === messages[0].id
|
||||
? setScrollToMessageRef
|
||||
: undefined
|
||||
}
|
||||
tips={tips[message.id] ?? {}}
|
||||
tips={tips[messages[0].id] ?? {}}
|
||||
/>
|
||||
))}
|
||||
{messages.length === 0 && (
|
||||
|
@ -140,7 +146,7 @@ export function GroupChat(props: {
|
|||
No messages yet. Why not{isMember ? ` ` : ' join and '}
|
||||
<button
|
||||
className={'cursor-pointer font-bold text-gray-700'}
|
||||
onClick={() => focusInput()}
|
||||
onClick={focusInput}
|
||||
>
|
||||
add one?
|
||||
</button>
|
||||
|
@ -158,15 +164,13 @@ export function GroupChat(props: {
|
|||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<CommentInputTextArea
|
||||
commentText={messageText}
|
||||
setComment={setMessageText}
|
||||
isReply={false}
|
||||
editor={editor}
|
||||
upload={upload}
|
||||
user={user}
|
||||
replyToUsername={replyToUsername}
|
||||
replyToUser={replyToUser}
|
||||
submitComment={submitMessage}
|
||||
isSubmitting={isSubmitting}
|
||||
enterToSubmitOnDesktop={true}
|
||||
setRef={setInputRef}
|
||||
submitOnEnter
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -175,18 +179,131 @@ 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 ? (
|
||||
<UsersIcon className="h-10 w-10" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDownIcon className={'h-10 w-10'} aria-hidden={'true'} />
|
||||
)}
|
||||
{privateUser && (
|
||||
<GroupChatNotificationsIcon
|
||||
group={group}
|
||||
privateUser={privateUser}
|
||||
shouldSetAsSeen={shouldShowChat}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupChatNotificationsIcon(props: {
|
||||
group: Group
|
||||
privateUser: PrivateUser
|
||||
shouldSetAsSeen: boolean
|
||||
}) {
|
||||
const { privateUser, group, shouldSetAsSeen } = props
|
||||
const preferredNotificationsForThisGroup = useUnseenPreferredNotifications(
|
||||
privateUser,
|
||||
{
|
||||
customHref: `/group/${group.slug}`,
|
||||
}
|
||||
)
|
||||
useEffect(() => {
|
||||
preferredNotificationsForThisGroup.forEach((notification) => {
|
||||
if (
|
||||
(shouldSetAsSeen && notification.isSeenOnHref?.includes('chat')) ||
|
||||
// old style chat notif that simply ended with the group slug
|
||||
notification.isSeenOnHref?.endsWith(group.slug)
|
||||
) {
|
||||
setNotificationsAsSeen([notification])
|
||||
}
|
||||
})
|
||||
}, [group.slug, preferredNotificationsForThisGroup, shouldSetAsSeen])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
preferredNotificationsForThisGroup.length > 0 && !shouldSetAsSeen
|
||||
? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500'
|
||||
: 'hidden'
|
||||
}
|
||||
></div>
|
||||
)
|
||||
}
|
||||
|
||||
const GroupMessage = memo(function GroupMessage_(props: {
|
||||
user: User | null | undefined
|
||||
comment: Comment
|
||||
comments: Comment[]
|
||||
group: Group
|
||||
onReplyClick?: (comment: Comment) => void
|
||||
setRef?: (ref: HTMLDivElement) => void
|
||||
highlight?: boolean
|
||||
tips: CommentTips
|
||||
}) {
|
||||
const { comment, onReplyClick, group, setRef, highlight, user, tips } = props
|
||||
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
||||
const isCreatorsComment = user && comment.userId === user.id
|
||||
const { comments, onReplyClick, group, setRef, highlight, user, tips } = props
|
||||
const first = comments[0]
|
||||
const { id, userUsername, userName, userAvatarUrl, createdTime } = first
|
||||
|
||||
const isCreatorsComment = user && first.userId === user.id
|
||||
return (
|
||||
<Col
|
||||
ref={setRef}
|
||||
|
@ -216,23 +333,21 @@ const GroupMessage = memo(function GroupMessage_(props: {
|
|||
prefix={'group'}
|
||||
slug={group.slug}
|
||||
createdTime={createdTime}
|
||||
elementId={comment.id}
|
||||
/>
|
||||
</Row>
|
||||
<Row className={'text-black'}>
|
||||
<TruncatedComment
|
||||
comment={text}
|
||||
moreHref={groupPath(group.slug)}
|
||||
shouldTruncate={false}
|
||||
elementId={id}
|
||||
/>
|
||||
</Row>
|
||||
<div className="mt-2 text-black">
|
||||
{comments.map((comment) => (
|
||||
<Content content={comment.content || comment.text} />
|
||||
))}
|
||||
</div>
|
||||
<Row>
|
||||
{!isCreatorsComment && onReplyClick && (
|
||||
<button
|
||||
className={
|
||||
'self-start py-1 text-xs font-bold text-gray-500 hover:underline'
|
||||
}
|
||||
onClick={() => onReplyClick(comment)}
|
||||
onClick={() => onReplyClick(first)}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
|
@ -242,7 +357,7 @@ const GroupMessage = memo(function GroupMessage_(props: {
|
|||
{formatMoney(sum(Object.values(tips)))}
|
||||
</span>
|
||||
)}
|
||||
{!isCreatorsComment && <Tipper comment={comment} tips={tips} />}
|
||||
{!isCreatorsComment && <Tipper comment={first} tips={tips} />}
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
import clsx from 'clsx'
|
||||
import { CreateGroupButton } from 'web/components/groups/create-group-button'
|
||||
import { useState } from 'react'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { useMemberGroups, useOpenGroups } from 'web/hooks/use-group'
|
||||
import { User } from 'common/user'
|
||||
import { searchInAny } from 'common/util/parse'
|
||||
|
||||
|
@ -27,10 +27,15 @@ export function GroupSelector(props: {
|
|||
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
|
||||
const { showSelector, showLabel, ignoreGroupIds } = options
|
||||
const [query, setQuery] = useState('')
|
||||
const memberGroups = (useMemberGroups(creator?.id) ?? []).filter(
|
||||
(group) => !ignoreGroupIds?.includes(group.id)
|
||||
)
|
||||
const filteredGroups = memberGroups.filter((group) =>
|
||||
const openGroups = useOpenGroups()
|
||||
const availableGroups = openGroups
|
||||
.concat(
|
||||
(useMemberGroups(creator?.id) ?? []).filter(
|
||||
(g) => !openGroups.map((og) => og.id).includes(g.id)
|
||||
)
|
||||
)
|
||||
.filter((group) => !ignoreGroupIds?.includes(group.id))
|
||||
const filteredGroups = availableGroups.filter((group) =>
|
||||
searchInAny(query, group.name)
|
||||
)
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Contract } from 'common/contract'
|
|||
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import { ContractsGrid } from './contract/contracts-list'
|
||||
import { ContractsGrid } from './contract/contracts-grid'
|
||||
import { Col } from './layout/col'
|
||||
import { Row } from './layout/row'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
|
|
|
@ -11,7 +11,6 @@ import { useUserLiquidity } from 'web/hooks/use-liquidity'
|
|||
import { Tabs } from './layout/tabs'
|
||||
import { NoLabel, YesLabel } from './outcome-label'
|
||||
import { Col } from './layout/col'
|
||||
import { InfoTooltip } from './info-tooltip'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export function LiquidityPanel(props: { contract: CPMMContract }) {
|
||||
|
@ -103,8 +102,7 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
|
|||
return (
|
||||
<>
|
||||
<div className="align-center mb-4 text-gray-500">
|
||||
Subsidize this market by adding M$ to the liquidity pool.{' '}
|
||||
<InfoTooltip text="The greater the M$ subsidy, the greater the incentive for traders to participate, the more accurate the market will be." />
|
||||
Subsidize this market by adding M$ to the liquidity pool.
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
|
@ -114,6 +112,7 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
|
|||
label="M$"
|
||||
error={error}
|
||||
disabled={isLoading}
|
||||
inputClassName="w-28"
|
||||
/>
|
||||
<button
|
||||
className={clsx('btn btn-primary ml-2', isLoading && 'btn-disabled')}
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { QrcodeIcon } from '@heroicons/react/outline'
|
||||
import { DotsHorizontalIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { fromNow } from 'web/lib/util/time'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Claim, Manalink } from 'common/manalink'
|
||||
import { useState } from 'react'
|
||||
import { ShareIconButton } from './share-icon-button'
|
||||
import { DotsHorizontalIcon } from '@heroicons/react/solid'
|
||||
import { contractDetailsButtonClassName } from './contract/contract-info-dialog'
|
||||
import { useUserById } from 'web/hooks/use-user'
|
||||
import getManalinkUrl from 'web/get-manalink-url'
|
||||
|
||||
export type ManalinkInfo = {
|
||||
expiresTime: number | null
|
||||
maxUses: number | null
|
||||
|
@ -78,7 +81,9 @@ export function ManalinkCardFromView(props: {
|
|||
const { className, link, highlightedSlug } = props
|
||||
const { message, amount, expiresTime, maxUses, claims } = link
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${200}x${200}&data=${getManalinkUrl(
|
||||
link.slug
|
||||
)}`
|
||||
return (
|
||||
<Col>
|
||||
<Col
|
||||
|
@ -127,6 +132,14 @@ export function ManalinkCardFromView(props: {
|
|||
>
|
||||
{formatMoney(amount)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => (window.location.href = qrUrl)}
|
||||
className={clsx(contractDetailsButtonClassName)}
|
||||
>
|
||||
<QrcodeIcon className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<ShareIconButton
|
||||
toastClassName={'-left-48 min-w-[250%]'}
|
||||
buttonClassName={'transition-colors'}
|
||||
|
|
|
@ -12,6 +12,7 @@ import dayjs from 'dayjs'
|
|||
import { Button } from '../button'
|
||||
import { getManalinkUrl } from 'web/pages/links'
|
||||
import { DuplicateIcon } from '@heroicons/react/outline'
|
||||
import { QRCode } from '../qr-code'
|
||||
|
||||
export function CreateLinksButton(props: {
|
||||
user: User
|
||||
|
@ -98,6 +99,8 @@ function CreateManalinkForm(props: {
|
|||
})
|
||||
}
|
||||
|
||||
const url = getManalinkUrl(highlightedSlug)
|
||||
|
||||
return (
|
||||
<>
|
||||
{!finishedCreating && (
|
||||
|
@ -199,17 +202,17 @@ function CreateManalinkForm(props: {
|
|||
copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : ''
|
||||
)}
|
||||
>
|
||||
<div className="w-full select-text truncate">
|
||||
{getManalinkUrl(highlightedSlug)}
|
||||
</div>
|
||||
<div className="w-full select-text truncate">{url}</div>
|
||||
<DuplicateIcon
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(getManalinkUrl(highlightedSlug))
|
||||
navigator.clipboard.writeText(url)
|
||||
setCopyPressed(true)
|
||||
}}
|
||||
className="my-auto ml-2 h-5 w-5 cursor-pointer transition hover:opacity-50"
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<QRCode url={url} className="self-center" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -18,7 +18,7 @@ import { ManifoldLogo } from './manifold-logo'
|
|||
import { MenuButton } from './menu'
|
||||
import { ProfileSummary } from './profile-menu'
|
||||
import NotificationsIcon from 'web/components/notifications-icon'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||
import { CreateQuestionButton } from 'web/components/create-question-button'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
|
@ -27,9 +27,9 @@ import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
|||
import { Group, GROUP_CHAT_SLUG } from 'common/group'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
||||
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
||||
import { PrivateUser } from 'common/user'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||
|
||||
const logout = async () => {
|
||||
// log out, and then reload the page, in case SSR wants to boot them out
|
||||
|
@ -61,26 +61,50 @@ function getMoreNavigation(user?: User | null) {
|
|||
}
|
||||
|
||||
if (!user) {
|
||||
return [
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
||||
]
|
||||
if (CHALLENGES_ENABLED)
|
||||
return [
|
||||
{ name: 'Challenges', href: '/challenges' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
||||
]
|
||||
else
|
||||
return [
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ name: 'Referrals', href: '/referrals' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Send M$', href: '/links' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||
{
|
||||
name: 'Sign out',
|
||||
href: '#',
|
||||
onClick: logout,
|
||||
},
|
||||
]
|
||||
if (CHALLENGES_ENABLED)
|
||||
return [
|
||||
{ name: 'Challenges', href: '/challenges' },
|
||||
{ name: 'Referrals', href: '/referrals' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Send M$', href: '/links' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||
{
|
||||
name: 'Sign out',
|
||||
href: '#',
|
||||
onClick: logout,
|
||||
},
|
||||
]
|
||||
else
|
||||
return [
|
||||
{ name: 'Referrals', href: '/referrals' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Send M$', href: '/links' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||
{
|
||||
name: 'Sign out',
|
||||
href: '#',
|
||||
onClick: logout,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const signedOutNavigation = [
|
||||
|
@ -120,6 +144,14 @@ function getMoreMobileNav() {
|
|||
return [
|
||||
...(IS_PRIVATE_MANIFOLD
|
||||
? []
|
||||
: CHALLENGES_ENABLED
|
||||
? [
|
||||
{ name: 'Challenges', href: '/challenges' },
|
||||
{ name: 'Referrals', href: '/referrals' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Send M$', href: '/links' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
]
|
||||
: [
|
||||
{ name: 'Referrals', href: '/referrals' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
|
@ -216,7 +248,7 @@ export default function Sidebar(props: { className?: string }) {
|
|||
) ?? []
|
||||
).map((group: Group) => ({
|
||||
name: group.name,
|
||||
href: `${groupPath(group.slug)}/${GROUP_CHAT_SLUG}`,
|
||||
href: `${groupPath(group.slug)}`,
|
||||
}))
|
||||
|
||||
return (
|
||||
|
@ -294,30 +326,22 @@ function GroupsList(props: {
|
|||
memberItems.length > 0 ? memberItems.length : undefined
|
||||
)
|
||||
|
||||
// Set notification as seen if our current page is equal to the isSeenOnHref property
|
||||
useEffect(() => {
|
||||
const currentPageWithoutQuery = currentPage.split('?')[0]
|
||||
const currentPageGroupSlug = currentPageWithoutQuery.split('/')[2]
|
||||
preferredNotifications.forEach((notification) => {
|
||||
if (
|
||||
notification.isSeenOnHref === currentPage ||
|
||||
// Old chat style group chat notif was just /group/slug
|
||||
(notification.isSeenOnHref &&
|
||||
currentPageWithoutQuery.includes(notification.isSeenOnHref)) ||
|
||||
// They're on the home page, so if they've a chat notif, they're seeing the chat
|
||||
(notification.isSeenOnHref?.endsWith(GROUP_CHAT_SLUG) &&
|
||||
currentPageWithoutQuery.endsWith(currentPageGroupSlug))
|
||||
) {
|
||||
setNotificationsAsSeen([notification])
|
||||
}
|
||||
})
|
||||
}, [currentPage, preferredNotifications])
|
||||
|
||||
const { height } = useWindowSize()
|
||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||
const remainingHeight =
|
||||
(height ?? window.innerHeight) - (containerRef?.offsetTop ?? 0)
|
||||
|
||||
const notifIsForThisItem = useMemo(
|
||||
() => (itemHref: string) =>
|
||||
preferredNotifications.some(
|
||||
(n) =>
|
||||
!n.isSeen &&
|
||||
(n.isSeenOnHref === itemHref ||
|
||||
n.isSeenOnHref?.replace('/chat', '') === itemHref)
|
||||
),
|
||||
[preferredNotifications]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarItem
|
||||
|
@ -332,19 +356,19 @@ function GroupsList(props: {
|
|||
>
|
||||
{memberItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
href={
|
||||
item.href +
|
||||
(notifIsForThisItem(item.href) ? '/' + GROUP_CHAT_SLUG : '')
|
||||
}
|
||||
key={item.name}
|
||||
onClick={trackCallback('sidebar: ' + item.name)}
|
||||
className={clsx(
|
||||
'cursor-pointer truncate',
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900',
|
||||
preferredNotifications.some(
|
||||
(n) =>
|
||||
!n.isSeen &&
|
||||
(n.isSeenOnHref === item.href ||
|
||||
n.isSeenOnHref === item.href.replace('/chat', ''))
|
||||
) && 'font-bold'
|
||||
notifIsForThisItem(item.href) && 'font-bold'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{item.name}</span>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
|
173
web/components/onboarding/welcome.tsx
Normal file
173
web/components/onboarding/welcome.tsx
Normal file
|
@ -0,0 +1,173 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'
|
||||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { updateUser } from 'web/lib/firebase/users'
|
||||
import { Col } from '../layout/col'
|
||||
import { Modal } from '../layout/modal'
|
||||
import { Row } from '../layout/row'
|
||||
import { Title } from '../title'
|
||||
|
||||
export default function Welcome() {
|
||||
const user = useUser()
|
||||
const [open, setOpen] = useState(true)
|
||||
const [page, setPage] = useState(0)
|
||||
const TOTAL_PAGES = 4
|
||||
|
||||
function increasePage() {
|
||||
if (page < TOTAL_PAGES - 1) {
|
||||
setPage(page + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function decreasePage() {
|
||||
if (page > 0) {
|
||||
setPage(page - 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function setUserHasSeenWelcome() {
|
||||
if (user) {
|
||||
await updateUser(user.id, { ['shouldShowWelcome']: false })
|
||||
}
|
||||
}
|
||||
|
||||
if (!user || !user.shouldShowWelcome) {
|
||||
return <></>
|
||||
} else
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
setOpen={(newOpen) => {
|
||||
setUserHasSeenWelcome()
|
||||
setOpen(newOpen)
|
||||
}}
|
||||
>
|
||||
<Col className="h-[32rem] place-content-between rounded-md bg-white px-8 py-6 text-sm font-light md:h-[40rem] md:text-lg">
|
||||
{page === 0 && <Page0 />}
|
||||
{page === 1 && <Page1 />}
|
||||
{page === 2 && <Page2 />}
|
||||
{page === 3 && <Page3 />}
|
||||
<Col>
|
||||
<Row className="place-content-between">
|
||||
<ChevronLeftIcon
|
||||
className={clsx(
|
||||
'h-10 w-10 text-gray-400 hover:text-gray-500',
|
||||
page === 0 ? 'disabled invisible' : ''
|
||||
)}
|
||||
onClick={decreasePage}
|
||||
/>
|
||||
<PageIndicator page={page} totalpages={TOTAL_PAGES} />
|
||||
<ChevronRightIcon
|
||||
className={clsx(
|
||||
'h-10 w-10 text-indigo-500 hover:text-indigo-600',
|
||||
page === TOTAL_PAGES - 1 ? 'disabled invisible' : ''
|
||||
)}
|
||||
onClick={increasePage}
|
||||
/>
|
||||
</Row>
|
||||
<u
|
||||
className="self-center text-xs text-gray-500"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setUserHasSeenWelcome()
|
||||
}}
|
||||
>
|
||||
I got the gist, exit welcome
|
||||
</u>
|
||||
</Col>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function PageIndicator(props: { page: number; totalpages: number }) {
|
||||
const { page, totalpages } = props
|
||||
return (
|
||||
<Row>
|
||||
{[...Array(totalpages)].map((e, i) => (
|
||||
<div
|
||||
className={clsx(
|
||||
'mx-1.5 my-auto h-1.5 w-1.5 rounded-full',
|
||||
i === page ? 'bg-indigo-500' : 'bg-gray-300'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
function Page0() {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
className="h-2/3 w-2/3 place-self-center object-contain"
|
||||
src="/welcome/manipurple.png"
|
||||
/>
|
||||
<Title className="text-center" text="Welcome to Manifold Markets!" />
|
||||
<p>
|
||||
Manifold Markets is a place where anyone can ask a question about the
|
||||
future.
|
||||
</p>
|
||||
<div className="mt-4">For example,</div>
|
||||
<div className="mt-2 font-normal text-indigo-700">
|
||||
“Will Michelle Obama be the next president of the United States?”
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Page1() {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
Your question becomes a prediction market that people can bet{' '}
|
||||
<span className="font-normal text-indigo-700">mana (M$)</span> on.
|
||||
</p>
|
||||
<div className="mt-8 font-semibold">The core idea</div>
|
||||
<div className="mt-2">
|
||||
If people have to put their mana where their mouth is, you’ll get a
|
||||
pretty accurate answer!
|
||||
</div>
|
||||
<video loop autoPlay className="my-4 h-full w-full">
|
||||
<source src="/welcome/mana-example.mp4" type="video/mp4" />
|
||||
Your browser does not support video
|
||||
</video>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Page2() {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<span className="mt-4 font-normal text-indigo-700">Mana (M$)</span> is
|
||||
the play money you bet with. You can also turn it into a real donation
|
||||
to charity, at a 100:1 ratio.
|
||||
</p>
|
||||
<div className="mt-8 font-semibold">Example</div>
|
||||
<p className="mt-2">
|
||||
When you donate <span className="font-semibold">M$1000</span> to
|
||||
Givewell, Manifold sends them{' '}
|
||||
<span className="font-semibold">$10 USD</span>.
|
||||
</p>
|
||||
<video loop autoPlay className="my-4 h-full w-full">
|
||||
<source src="/welcome/charity.mp4" type="video/mp4" />
|
||||
Your browser does not support video
|
||||
</video>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Page3() {
|
||||
return (
|
||||
<>
|
||||
<img className="mx-auto object-contain" src="/welcome/treasure.png" />
|
||||
<Title className="mx-auto" text="Let's start predicting!" />
|
||||
<p className="mb-8">
|
||||
As a thank you for signing up, we’ve sent you{' '}
|
||||
<span className="font-normal text-indigo-700">M$1000 Mana</span>{' '}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import clsx from 'clsx'
|
||||
import { Spacer } from './layout/spacer'
|
||||
|
||||
export function Pagination(props: {
|
||||
page: number
|
||||
|
@ -23,6 +24,8 @@ export function Pagination(props: {
|
|||
|
||||
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
|
||||
|
||||
if (maxPage === 0) return <Spacer h={4} />
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={clsx(
|
||||
|
|
|
@ -10,8 +10,9 @@ import { PortfolioValueGraph } from './portfolio-value-graph'
|
|||
export const PortfolioValueSection = memo(
|
||||
function PortfolioValueSection(props: {
|
||||
portfolioHistory: PortfolioMetrics[]
|
||||
disableSelector?: boolean
|
||||
}) {
|
||||
const { portfolioHistory } = props
|
||||
const { portfolioHistory, disableSelector } = props
|
||||
const lastPortfolioMetrics = last(portfolioHistory)
|
||||
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
|
||||
|
||||
|
@ -30,7 +31,9 @@ export const PortfolioValueSection = memo(
|
|||
<div>
|
||||
<Row className="gap-8">
|
||||
<div className="mb-4 w-full">
|
||||
<Col>
|
||||
<Col
|
||||
className={disableSelector ? 'items-center justify-center' : ''}
|
||||
>
|
||||
<div className="text-sm text-gray-500">Portfolio value</div>
|
||||
<div className="text-lg">
|
||||
{formatMoney(
|
||||
|
@ -40,16 +43,18 @@ export const PortfolioValueSection = memo(
|
|||
</div>
|
||||
</Col>
|
||||
</div>
|
||||
<select
|
||||
className="select select-bordered self-start"
|
||||
onChange={(e) => {
|
||||
setPortfolioPeriod(e.target.value as Period)
|
||||
}}
|
||||
>
|
||||
<option value="allTime">{allTimeLabel}</option>
|
||||
<option value="weekly">7 days</option>
|
||||
<option value="daily">24 hours</option>
|
||||
</select>
|
||||
{!disableSelector && (
|
||||
<select
|
||||
className="select select-bordered self-start"
|
||||
onChange={(e) => {
|
||||
setPortfolioPeriod(e.target.value as Period)
|
||||
}}
|
||||
>
|
||||
<option value="allTime">{allTimeLabel}</option>
|
||||
<option value="weekly">7 days</option>
|
||||
<option value="daily">24 hours</option>
|
||||
</select>
|
||||
)}
|
||||
</Row>
|
||||
<PortfolioValueGraph
|
||||
portfolioHistory={portfolioHistory}
|
||||
|
|
16
web/components/qr-code.tsx
Normal file
16
web/components/qr-code.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
export function QRCode(props: {
|
||||
url: string
|
||||
className?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}) {
|
||||
const { url, className, width, height } = {
|
||||
width: 200,
|
||||
height: 200,
|
||||
...props,
|
||||
}
|
||||
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${width}x${height}&data=${url}`
|
||||
|
||||
return <img src={qrUrl} width={width} height={height} className={className} />
|
||||
}
|
18
web/components/share-market-button.tsx
Normal file
18
web/components/share-market-button.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts'
|
||||
import { CopyLinkButton } from './copy-link-button'
|
||||
|
||||
export function ShareMarketButton(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
|
||||
const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}`
|
||||
|
||||
return (
|
||||
<CopyLinkButton
|
||||
url={url}
|
||||
displayUrl={contractUrl(contract)}
|
||||
buttonClassName="btn-md rounded-l-none"
|
||||
toastClassName={'-left-28 mt-1'}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
import clsx from 'clsx'
|
||||
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
|
||||
import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts'
|
||||
import { CopyLinkButton } from './copy-link-button'
|
||||
import { Col } from './layout/col'
|
||||
import { Row } from './layout/row'
|
||||
|
||||
export function ShareMarket(props: { contract: Contract; className?: string }) {
|
||||
const { contract, className } = props
|
||||
|
||||
const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}`
|
||||
|
||||
return (
|
||||
<Col className={clsx(className, 'gap-3')}>
|
||||
<div>Share your market</div>
|
||||
<Row className="mb-6 items-center">
|
||||
<CopyLinkButton
|
||||
url={url}
|
||||
displayUrl={contractUrl(contract)}
|
||||
buttonClassName="btn-md rounded-l-none"
|
||||
toastClassName={'-left-28 mt-1'}
|
||||
/>
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
}
|
|
@ -2,16 +2,20 @@ import React from 'react'
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { Button } from './button'
|
||||
|
||||
export function SignUpPrompt() {
|
||||
export function SignUpPrompt(props: { label?: string; className?: string }) {
|
||||
const { label, className } = props
|
||||
const user = useUser()
|
||||
|
||||
return user === null ? (
|
||||
<button
|
||||
className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-indigo-500 to-blue-500 px-10 text-lg font-medium normal-case hover:from-indigo-600 hover:to-blue-600"
|
||||
<Button
|
||||
onClick={withTracking(firebaseLogin, 'sign up to bet')}
|
||||
className={className}
|
||||
size="lg"
|
||||
color="gradient"
|
||||
>
|
||||
Sign up to bet!
|
||||
</button>
|
||||
{label ?? 'Sign up to bet!'}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ export function Title(props: { text: string; className?: string }) {
|
|||
return (
|
||||
<h1
|
||||
className={clsx(
|
||||
'my-4 inline-block text-2xl text-indigo-700 sm:my-6 sm:text-3xl',
|
||||
'my-4 inline-block text-2xl font-normal text-indigo-700 sm:my-6 sm:text-3xl',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -12,7 +12,7 @@ import {
|
|||
unfollow,
|
||||
User,
|
||||
} from 'web/lib/firebase/users'
|
||||
import { CreatorContractsList } from './contract/contracts-list'
|
||||
import { CreatorContractsList } from './contract/contracts-grid'
|
||||
import { SEO } from './SEO'
|
||||
import { Page } from './page'
|
||||
import { SiteLink } from './site-link'
|
||||
|
|
|
@ -9,12 +9,27 @@ import {
|
|||
} from 'web/lib/firebase/bets'
|
||||
import { LimitBet } from 'common/bet'
|
||||
|
||||
export const useBets = (contractId: string) => {
|
||||
export const useBets = (
|
||||
contractId: string,
|
||||
options?: { filterChallenges: boolean; filterRedemptions: boolean }
|
||||
) => {
|
||||
const [bets, setBets] = useState<Bet[] | undefined>()
|
||||
|
||||
const filterChallenges = !!options?.filterChallenges
|
||||
const filterRedemptions = !!options?.filterRedemptions
|
||||
useEffect(() => {
|
||||
if (contractId) return listenForBets(contractId, setBets)
|
||||
}, [contractId])
|
||||
if (contractId)
|
||||
return listenForBets(contractId, (bets) => {
|
||||
if (filterChallenges || filterRedemptions)
|
||||
setBets(
|
||||
bets.filter(
|
||||
(bet) =>
|
||||
(filterChallenges ? !bet.challengeSlug : true) &&
|
||||
(filterRedemptions ? !bet.isRedemption : true)
|
||||
)
|
||||
)
|
||||
else setBets(bets)
|
||||
})
|
||||
}, [contractId, filterChallenges, filterRedemptions])
|
||||
|
||||
return bets
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
listenForGroup,
|
||||
listenForGroups,
|
||||
listenForMemberGroups,
|
||||
listenForOpenGroups,
|
||||
listGroups,
|
||||
} from 'web/lib/firebase/groups'
|
||||
import { getUser, getUsers } from 'web/lib/firebase/users'
|
||||
|
@ -32,6 +33,16 @@ export const useGroups = () => {
|
|||
return groups
|
||||
}
|
||||
|
||||
export const useOpenGroups = () => {
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
return listenForOpenGroups(setGroups)
|
||||
}, [])
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
export const useMemberGroups = (
|
||||
userId: string | null | undefined,
|
||||
options?: { withChatEnabled: boolean },
|
||||
|
|
|
@ -6,7 +6,7 @@ import { User, writeReferralInfo } from 'web/lib/firebase/users'
|
|||
export const useSaveReferral = (
|
||||
user?: User | null,
|
||||
options?: {
|
||||
defaultReferrer?: string
|
||||
defaultReferrerUsername?: string
|
||||
contractId?: string
|
||||
groupId?: string
|
||||
}
|
||||
|
@ -18,10 +18,14 @@ export const useSaveReferral = (
|
|||
referrer?: string
|
||||
}
|
||||
|
||||
const actualReferrer = referrer || options?.defaultReferrer
|
||||
const referrerOrDefault = referrer || options?.defaultReferrerUsername
|
||||
|
||||
if (!user && router.isReady && actualReferrer) {
|
||||
writeReferralInfo(actualReferrer, options?.contractId, options?.groupId)
|
||||
if (!user && router.isReady && referrerOrDefault) {
|
||||
writeReferralInfo(referrerOrDefault, {
|
||||
contractId: options?.contractId,
|
||||
overwriteReferralUsername: referrer,
|
||||
groupId: options?.groupId,
|
||||
})
|
||||
}
|
||||
}, [user, router, options])
|
||||
}
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import { defaults, debounce } from 'lodash'
|
||||
import { debounce } from 'lodash'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchBox } from 'react-instantsearch-hooks-web'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { DEFAULT_SORT } from 'web/components/contract-search'
|
||||
|
||||
const MARKETS_SORT = 'markets_sort'
|
||||
|
@ -27,98 +25,71 @@ export function getSavedSort() {
|
|||
}
|
||||
}
|
||||
|
||||
export function useInitialQueryAndSort(options?: {
|
||||
defaultSort: Sort
|
||||
export function useQueryAndSortParams(options?: {
|
||||
defaultSort?: Sort
|
||||
shouldLoadFromStorage?: boolean
|
||||
}) {
|
||||
const { defaultSort, shouldLoadFromStorage } = defaults(options, {
|
||||
defaultSort: DEFAULT_SORT,
|
||||
shouldLoadFromStorage: true,
|
||||
})
|
||||
const { defaultSort = DEFAULT_SORT, shouldLoadFromStorage = true } =
|
||||
options ?? {}
|
||||
const router = useRouter()
|
||||
|
||||
const [initialSort, setInitialSort] = useState<Sort | undefined>(undefined)
|
||||
const [initialQuery, setInitialQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
// If there's no sort option, then set the one from localstorage
|
||||
if (router.isReady) {
|
||||
const { s: sort, q: query } = router.query as {
|
||||
q?: string
|
||||
s?: Sort
|
||||
}
|
||||
|
||||
setInitialQuery(query ?? '')
|
||||
|
||||
if (!sort && shouldLoadFromStorage) {
|
||||
console.log('ready loading from storage ', sort ?? defaultSort)
|
||||
const localSort = getSavedSort()
|
||||
if (localSort) {
|
||||
// Use replace to not break navigating back.
|
||||
router.replace(
|
||||
{ query: { ...router.query, s: localSort } },
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
)
|
||||
}
|
||||
setInitialSort(localSort ?? defaultSort)
|
||||
} else {
|
||||
setInitialSort(sort ?? defaultSort)
|
||||
}
|
||||
}
|
||||
}, [defaultSort, router.isReady, shouldLoadFromStorage])
|
||||
|
||||
return {
|
||||
initialSort,
|
||||
initialQuery,
|
||||
const { s: sort, q: query } = router.query as {
|
||||
q?: string
|
||||
s?: Sort
|
||||
}
|
||||
}
|
||||
|
||||
export function useUpdateQueryAndSort(props: {
|
||||
shouldLoadFromStorage: boolean
|
||||
}) {
|
||||
const { shouldLoadFromStorage } = props
|
||||
const router = useRouter()
|
||||
|
||||
const setSort = (sort: Sort | undefined) => {
|
||||
if (sort !== router.query.s) {
|
||||
router.query.s = sort
|
||||
router.replace({ query: { ...router.query, s: sort } }, undefined, {
|
||||
shallow: true,
|
||||
})
|
||||
if (shouldLoadFromStorage) {
|
||||
localStorage.setItem(MARKETS_SORT, sort || '')
|
||||
}
|
||||
router.replace({ query: { ...router.query, s: sort } }, undefined, {
|
||||
shallow: true,
|
||||
})
|
||||
if (shouldLoadFromStorage) {
|
||||
localStorage.setItem(MARKETS_SORT, sort || '')
|
||||
}
|
||||
}
|
||||
|
||||
const { query, refine } = useSearchBox()
|
||||
const [queryState, setQueryState] = useState(query)
|
||||
|
||||
useEffect(() => {
|
||||
setQueryState(query)
|
||||
}, [query])
|
||||
|
||||
// Debounce router query update.
|
||||
const pushQuery = useMemo(
|
||||
() =>
|
||||
debounce((query: string | undefined) => {
|
||||
if (query) {
|
||||
router.query.q = query
|
||||
} else {
|
||||
delete router.query.q
|
||||
}
|
||||
router.replace({ query: router.query }, undefined, {
|
||||
const queryObj = { ...router.query, q: query }
|
||||
if (!query) delete queryObj.q
|
||||
router.replace({ query: queryObj }, undefined, {
|
||||
shallow: true,
|
||||
})
|
||||
track('search', { query })
|
||||
}, 500),
|
||||
}, 100),
|
||||
[router]
|
||||
)
|
||||
|
||||
const setQuery = (query: string | undefined) => {
|
||||
refine(query ?? '')
|
||||
setQueryState(query)
|
||||
pushQuery(query)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// If there's no sort option, then set the one from localstorage
|
||||
if (router.isReady && !sort && shouldLoadFromStorage) {
|
||||
const localSort = localStorage.getItem(MARKETS_SORT) as Sort
|
||||
if (localSort && localSort !== defaultSort) {
|
||||
// Use replace to not break navigating back.
|
||||
router.replace(
|
||||
{ query: { ...router.query, s: localSort } },
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
sort: sort ?? defaultSort,
|
||||
query: queryState ?? '',
|
||||
setSort,
|
||||
setQuery,
|
||||
query,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useContext, useEffect, useState } from 'react'
|
|||
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
||||
import { QueryClient } from 'react-query'
|
||||
|
||||
import { doc, DocumentData } from 'firebase/firestore'
|
||||
import { doc, DocumentData, where } from 'firebase/firestore'
|
||||
import { PrivateUser } from 'common/user'
|
||||
import {
|
||||
getUser,
|
||||
|
|
|
@ -80,3 +80,11 @@ export function claimManalink(params: any) {
|
|||
export function createGroup(params: any) {
|
||||
return call(getFunctionUrl('creategroup'), 'POST', params)
|
||||
}
|
||||
|
||||
export function acceptChallenge(params: any) {
|
||||
return call(getFunctionUrl('acceptchallenge'), 'POST', params)
|
||||
}
|
||||
|
||||
export function getCurrentUser(params: any) {
|
||||
return call(getFunctionUrl('getcurrentuser'), 'GET', params)
|
||||
}
|
||||
|
|
|
@ -2,53 +2,73 @@ import { PROJECT_ID } from 'common/envs/constants'
|
|||
import { setCookie, getCookies } from '../util/cookie'
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
|
||||
const TOKEN_KINDS = ['refresh', 'id'] as const
|
||||
type TokenKind = typeof TOKEN_KINDS[number]
|
||||
const ONE_HOUR_SECS = 60 * 60
|
||||
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
|
||||
const TOKEN_KINDS = ['refresh', 'id', 'custom'] as const
|
||||
const TOKEN_AGES = {
|
||||
id: ONE_HOUR_SECS,
|
||||
refresh: TEN_YEARS_SECS,
|
||||
custom: ONE_HOUR_SECS,
|
||||
} as const
|
||||
export type TokenKind = typeof TOKEN_KINDS[number]
|
||||
|
||||
const getAuthCookieName = (kind: TokenKind) => {
|
||||
const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replace(/-/g, '_')
|
||||
return `FIREBASE_TOKEN_${suffix}`
|
||||
}
|
||||
|
||||
const ID_COOKIE_NAME = getAuthCookieName('id')
|
||||
const REFRESH_COOKIE_NAME = getAuthCookieName('refresh')
|
||||
const COOKIE_NAMES = Object.fromEntries(
|
||||
TOKEN_KINDS.map((k) => [k, getAuthCookieName(k)])
|
||||
) as Record<TokenKind, string>
|
||||
|
||||
export const getAuthCookies = (request?: IncomingMessage) => {
|
||||
const data = request != null ? request.headers.cookie ?? '' : document.cookie
|
||||
const cookies = getCookies(data)
|
||||
return {
|
||||
idToken: cookies[ID_COOKIE_NAME] as string | undefined,
|
||||
refreshToken: cookies[REFRESH_COOKIE_NAME] as string | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const setAuthCookies = (
|
||||
idToken?: string,
|
||||
refreshToken?: string,
|
||||
response?: ServerResponse
|
||||
) => {
|
||||
// these tokens last an hour
|
||||
const idMaxAge = idToken != null ? 60 * 60 : 0
|
||||
const idCookie = setCookie(ID_COOKIE_NAME, idToken ?? '', [
|
||||
['path', '/'],
|
||||
['max-age', idMaxAge.toString()],
|
||||
['samesite', 'lax'],
|
||||
['secure'],
|
||||
])
|
||||
// these tokens don't expire
|
||||
const refreshMaxAge = refreshToken != null ? 60 * 60 * 24 * 365 * 10 : 0
|
||||
const refreshCookie = setCookie(REFRESH_COOKIE_NAME, refreshToken ?? '', [
|
||||
['path', '/'],
|
||||
['max-age', refreshMaxAge.toString()],
|
||||
['samesite', 'lax'],
|
||||
['secure'],
|
||||
])
|
||||
if (response != null) {
|
||||
response.setHeader('Set-Cookie', [idCookie, refreshCookie])
|
||||
const getCookieDataIsomorphic = (req?: IncomingMessage) => {
|
||||
if (req != null) {
|
||||
return req.headers.cookie ?? ''
|
||||
} else if (document != null) {
|
||||
return document.cookie
|
||||
} else {
|
||||
document.cookie = idCookie
|
||||
document.cookie = refreshCookie
|
||||
throw new Error(
|
||||
'Neither request nor document is available; no way to get cookies.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteAuthCookies = () => setAuthCookies()
|
||||
const setCookieDataIsomorphic = (cookies: string[], res?: ServerResponse) => {
|
||||
if (res != null) {
|
||||
res.setHeader('Set-Cookie', cookies)
|
||||
} else if (document != null) {
|
||||
for (const ck of cookies) {
|
||||
document.cookie = ck
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
'Neither response nor document is available; no way to set cookies.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const getTokensFromCookies = (req?: IncomingMessage) => {
|
||||
const cookies = getCookies(getCookieDataIsomorphic(req))
|
||||
return Object.fromEntries(
|
||||
TOKEN_KINDS.map((k) => [k, cookies[COOKIE_NAMES[k]]])
|
||||
) as Partial<Record<TokenKind, string>>
|
||||
}
|
||||
|
||||
export const setTokenCookies = (
|
||||
cookies: Partial<Record<TokenKind, string | undefined>>,
|
||||
res?: ServerResponse
|
||||
) => {
|
||||
const data = TOKEN_KINDS.filter((k) => k in cookies).map((k) => {
|
||||
const maxAge = cookies[k] ? TOKEN_AGES[k as TokenKind] : 0
|
||||
return setCookie(COOKIE_NAMES[k], cookies[k] ?? '', [
|
||||
['path', '/'],
|
||||
['max-age', maxAge.toString()],
|
||||
['samesite', 'lax'],
|
||||
['secure'],
|
||||
])
|
||||
})
|
||||
setCookieDataIsomorphic(data, res)
|
||||
}
|
||||
|
||||
export const deleteTokenCookies = (res?: ServerResponse) =>
|
||||
setTokenCookies({ id: undefined, refresh: undefined, custom: undefined }, res)
|
||||
|
|
150
web/lib/firebase/challenges.ts
Normal file
150
web/lib/firebase/challenges.ts
Normal file
|
@ -0,0 +1,150 @@
|
|||
import {
|
||||
collectionGroup,
|
||||
doc,
|
||||
getDoc,
|
||||
orderBy,
|
||||
query,
|
||||
setDoc,
|
||||
where,
|
||||
} from 'firebase/firestore'
|
||||
import { Challenge } from 'common/challenge'
|
||||
import { customAlphabet } from 'nanoid'
|
||||
import { coll, listenForValue, listenForValues } from './utils'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { User } from 'common/user'
|
||||
import { db } from './init'
|
||||
import { Contract } from 'common/contract'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
|
||||
export const challenges = (contractId: string) =>
|
||||
coll<Challenge>(`contracts/${contractId}/challenges`)
|
||||
|
||||
export function getChallengeUrl(challenge: Challenge) {
|
||||
return `https://${ENV_CONFIG.domain}/challenges/${challenge.creatorUsername}/${challenge.contractSlug}/${challenge.slug}`
|
||||
}
|
||||
export async function createChallenge(data: {
|
||||
creator: User
|
||||
outcome: 'YES' | 'NO' | number
|
||||
contract: Contract
|
||||
creatorAmount: number
|
||||
acceptorAmount: number
|
||||
expiresTime: number | null
|
||||
message: string
|
||||
}) {
|
||||
const {
|
||||
creator,
|
||||
creatorAmount,
|
||||
expiresTime,
|
||||
message,
|
||||
contract,
|
||||
outcome,
|
||||
acceptorAmount,
|
||||
} = data
|
||||
|
||||
// At 100 IDs per hour, using this alphabet and 8 chars, there's a 1% chance of collision in 2 years
|
||||
// See https://zelark.github.io/nano-id-cc/
|
||||
const nanoid = customAlphabet(
|
||||
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
8
|
||||
)
|
||||
const slug = nanoid()
|
||||
|
||||
if (creatorAmount <= 0 || isNaN(creatorAmount) || !isFinite(creatorAmount))
|
||||
return null
|
||||
|
||||
const challenge: Challenge = {
|
||||
slug,
|
||||
creatorId: creator.id,
|
||||
creatorUsername: creator.username,
|
||||
creatorName: creator.name,
|
||||
creatorAvatarUrl: creator.avatarUrl,
|
||||
creatorAmount,
|
||||
creatorOutcome: outcome.toString(),
|
||||
creatorOutcomeProb: creatorAmount / (creatorAmount + acceptorAmount),
|
||||
acceptorOutcome: outcome === 'YES' ? 'NO' : 'YES',
|
||||
acceptorAmount,
|
||||
contractSlug: contract.slug,
|
||||
contractId: contract.id,
|
||||
contractQuestion: contract.question,
|
||||
contractCreatorUsername: contract.creatorUsername,
|
||||
createdTime: Date.now(),
|
||||
expiresTime,
|
||||
maxUses: 1,
|
||||
acceptedByUserIds: [],
|
||||
acceptances: [],
|
||||
isResolved: false,
|
||||
message,
|
||||
}
|
||||
|
||||
await setDoc(doc(challenges(contract.id), slug), challenge)
|
||||
return challenge
|
||||
}
|
||||
|
||||
// TODO: This required an index, make sure to also set up in prod
|
||||
function listUserChallenges(fromId?: string) {
|
||||
return query(
|
||||
collectionGroup(db, 'challenges'),
|
||||
where('creatorId', '==', fromId),
|
||||
orderBy('createdTime', 'desc')
|
||||
)
|
||||
}
|
||||
|
||||
function listChallenges() {
|
||||
return query(collectionGroup(db, 'challenges'))
|
||||
}
|
||||
|
||||
export const useAcceptedChallenges = () => {
|
||||
const [links, setLinks] = useState<Challenge[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
listenForValues(listChallenges(), (challenges: Challenge[]) => {
|
||||
setLinks(
|
||||
challenges
|
||||
.sort((a: Challenge, b: Challenge) => b.createdTime - a.createdTime)
|
||||
.filter((challenge) => challenge.acceptedByUserIds.length > 0)
|
||||
)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
export function listenForChallenge(
|
||||
slug: string,
|
||||
contractId: string,
|
||||
setLinks: (challenge: Challenge | null) => void
|
||||
) {
|
||||
return listenForValue<Challenge>(doc(challenges(contractId), slug), setLinks)
|
||||
}
|
||||
|
||||
export function useChallenge(slug: string, contractId: string | undefined) {
|
||||
const [challenge, setChallenge] = useState<Challenge | null>()
|
||||
useEffect(() => {
|
||||
if (slug && contractId) {
|
||||
listenForChallenge(slug, contractId, setChallenge)
|
||||
}
|
||||
}, [contractId, slug])
|
||||
return challenge
|
||||
}
|
||||
|
||||
export function listenForUserChallenges(
|
||||
fromId: string | undefined,
|
||||
setLinks: (links: Challenge[]) => void
|
||||
) {
|
||||
return listenForValues<Challenge>(listUserChallenges(fromId), setLinks)
|
||||
}
|
||||
|
||||
export const useUserChallenges = (fromId?: string) => {
|
||||
const [links, setLinks] = useState<Challenge[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (fromId) return listenForUserChallenges(fromId, setLinks)
|
||||
}, [fromId])
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
export const getChallenge = async (slug: string, contractId: string) => {
|
||||
const challenge = await getDoc(doc(challenges(contractId), slug))
|
||||
return challenge.data() as Challenge
|
||||
}
|
|
@ -14,6 +14,7 @@ import { User } from 'common/user'
|
|||
import { Comment } from 'common/comment'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { JSONContent } from '@tiptap/react'
|
||||
|
||||
export type { Comment }
|
||||
|
||||
|
@ -21,7 +22,7 @@ export const MAX_COMMENT_LENGTH = 10000
|
|||
|
||||
export async function createCommentOnContract(
|
||||
contractId: string,
|
||||
text: string,
|
||||
content: JSONContent,
|
||||
commenter: User,
|
||||
betId?: string,
|
||||
answerOutcome?: string,
|
||||
|
@ -34,7 +35,7 @@ export async function createCommentOnContract(
|
|||
id: ref.id,
|
||||
contractId,
|
||||
userId: commenter.id,
|
||||
text: text.slice(0, MAX_COMMENT_LENGTH),
|
||||
content: content,
|
||||
createdTime: Date.now(),
|
||||
userName: commenter.name,
|
||||
userUsername: commenter.username,
|
||||
|
@ -53,7 +54,7 @@ export async function createCommentOnContract(
|
|||
}
|
||||
export async function createCommentOnGroup(
|
||||
groupId: string,
|
||||
text: string,
|
||||
content: JSONContent,
|
||||
user: User,
|
||||
replyToCommentId?: string
|
||||
) {
|
||||
|
@ -62,7 +63,7 @@ export async function createCommentOnGroup(
|
|||
id: ref.id,
|
||||
groupId,
|
||||
userId: user.id,
|
||||
text: text.slice(0, MAX_COMMENT_LENGTH),
|
||||
content: content,
|
||||
createdTime: Date.now(),
|
||||
userName: user.name,
|
||||
userUsername: user.username,
|
||||
|
@ -81,6 +82,7 @@ export async function createCommentOnGroup(
|
|||
function getCommentsCollection(contractId: string) {
|
||||
return collection(db, 'contracts', contractId, 'comments')
|
||||
}
|
||||
|
||||
function getCommentsOnGroupCollection(groupId: string) {
|
||||
return collection(db, 'groups', groupId, 'comments')
|
||||
}
|
||||
|
@ -91,6 +93,14 @@ export async function listAllComments(contractId: string) {
|
|||
return comments
|
||||
}
|
||||
|
||||
export async function listAllCommentsOnGroup(groupId: string) {
|
||||
const comments = await getValues<Comment>(
|
||||
getCommentsOnGroupCollection(groupId)
|
||||
)
|
||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
return comments
|
||||
}
|
||||
|
||||
export function listenForCommentsOnContract(
|
||||
contractId: string,
|
||||
setComments: (comments: Comment[]) => void
|
||||
|
|
|
@ -35,6 +35,13 @@ export function contractPath(contract: Contract) {
|
|||
return `/${contract.creatorUsername}/${contract.slug}`
|
||||
}
|
||||
|
||||
export function contractPathWithoutContract(
|
||||
creatorUsername: string,
|
||||
slug: string
|
||||
) {
|
||||
return `/${creatorUsername}/${slug}`
|
||||
}
|
||||
|
||||
export function homeContractPath(contract: Contract) {
|
||||
return `/home?c=${contract.slug}`
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@ import {
|
|||
} from 'firebase/firestore'
|
||||
import { sortBy, uniq } from 'lodash'
|
||||
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
|
||||
import { updateContract } from './contracts'
|
||||
import {
|
||||
coll,
|
||||
getValue,
|
||||
|
@ -17,6 +16,7 @@ import {
|
|||
listenForValues,
|
||||
} from './utils'
|
||||
import { Contract } from 'common/contract'
|
||||
import { updateContract } from 'web/lib/firebase/contracts'
|
||||
|
||||
export const groups = coll<Group>('groups')
|
||||
|
||||
|
@ -52,6 +52,13 @@ export function listenForGroups(setGroups: (groups: Group[]) => void) {
|
|||
return listenForValues(groups, setGroups)
|
||||
}
|
||||
|
||||
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {
|
||||
return listenForValues(
|
||||
query(groups, where('anyoneCanJoin', '==', true)),
|
||||
setGroups
|
||||
)
|
||||
}
|
||||
|
||||
export function getGroup(groupId: string) {
|
||||
return getValue<Group>(doc(groups, groupId))
|
||||
}
|
||||
|
@ -129,23 +136,23 @@ export async function addContractToGroup(
|
|||
contract: Contract,
|
||||
userId: string
|
||||
) {
|
||||
if (!contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
|
||||
const newGroupLinks = [
|
||||
...(contract.groupLinks ?? []),
|
||||
{
|
||||
groupId: group.id,
|
||||
createdTime: Date.now(),
|
||||
slug: group.slug,
|
||||
userId,
|
||||
name: group.name,
|
||||
} as GroupLink,
|
||||
]
|
||||
if (!canModifyGroupContracts(group, userId)) return
|
||||
const newGroupLinks = [
|
||||
...(contract.groupLinks ?? []),
|
||||
{
|
||||
groupId: group.id,
|
||||
createdTime: Date.now(),
|
||||
slug: group.slug,
|
||||
userId,
|
||||
name: group.name,
|
||||
} as GroupLink,
|
||||
]
|
||||
// It's good to update the contract first, so the on-update-group trigger doesn't re-add them
|
||||
await updateContract(contract.id, {
|
||||
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
|
||||
groupLinks: newGroupLinks,
|
||||
})
|
||||
|
||||
await updateContract(contract.id, {
|
||||
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
|
||||
groupLinks: newGroupLinks,
|
||||
})
|
||||
}
|
||||
if (!group.contractIds.includes(contract.id)) {
|
||||
return await updateGroup(group, {
|
||||
contractIds: uniq([...group.contractIds, contract.id]),
|
||||
|
@ -160,8 +167,11 @@ export async function addContractToGroup(
|
|||
|
||||
export async function removeContractFromGroup(
|
||||
group: Group,
|
||||
contract: Contract
|
||||
contract: Contract,
|
||||
userId: string
|
||||
) {
|
||||
if (!canModifyGroupContracts(group, userId)) return
|
||||
|
||||
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
|
||||
const newGroupLinks = contract.groupLinks?.filter(
|
||||
(link) => link.slug !== group.slug
|
||||
|
@ -186,29 +196,10 @@ export async function removeContractFromGroup(
|
|||
}
|
||||
}
|
||||
|
||||
export async function setContractGroupLinks(
|
||||
group: Group,
|
||||
contractId: string,
|
||||
userId: string
|
||||
) {
|
||||
await updateContract(contractId, {
|
||||
groupSlugs: [group.slug],
|
||||
groupLinks: [
|
||||
{
|
||||
groupId: group.id,
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
userId,
|
||||
createdTime: Date.now(),
|
||||
} as GroupLink,
|
||||
],
|
||||
})
|
||||
return await updateGroup(group, {
|
||||
contractIds: uniq([...group.contractIds, contractId]),
|
||||
})
|
||||
.then(() => group)
|
||||
.catch((err) => {
|
||||
console.error('error adding contract to group', err)
|
||||
return err
|
||||
})
|
||||
export function canModifyGroupContracts(group: Group, userId: string) {
|
||||
return (
|
||||
group.creatorId === userId ||
|
||||
group.memberIds.includes(userId) ||
|
||||
group.anyoneCanJoin
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,9 +1,25 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import fetch from 'node-fetch'
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants'
|
||||
import { getAuthCookies, setAuthCookies } from './auth'
|
||||
import { GetServerSideProps, GetServerSidePropsContext } from 'next'
|
||||
import { getFunctionUrl } from 'common/api'
|
||||
import { UserCredential } from 'firebase/auth'
|
||||
import {
|
||||
getTokensFromCookies,
|
||||
setTokenCookies,
|
||||
deleteTokenCookies,
|
||||
} from './auth'
|
||||
import {
|
||||
GetServerSideProps,
|
||||
GetServerSidePropsContext,
|
||||
GetServerSidePropsResult,
|
||||
} from 'next'
|
||||
|
||||
// server firebase SDK
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
// client firebase SDK
|
||||
import { app as clientApp } from './init'
|
||||
import { getAuth, signInWithCustomToken } from 'firebase/auth'
|
||||
|
||||
const ensureApp = async () => {
|
||||
// Note: firebase-admin can only be imported from a server context,
|
||||
|
@ -33,7 +49,21 @@ const requestFirebaseIdToken = async (refreshToken: string) => {
|
|||
if (!result.ok) {
|
||||
throw new Error(`Could not refresh ID token: ${await result.text()}`)
|
||||
}
|
||||
return (await result.json()) as any
|
||||
return (await result.json()) as { id_token: string; refresh_token: string }
|
||||
}
|
||||
|
||||
const requestManifoldCustomToken = async (idToken: string) => {
|
||||
const functionUrl = getFunctionUrl('getcustomtoken')
|
||||
const result = await fetch(functionUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${idToken}`,
|
||||
},
|
||||
})
|
||||
if (!result.ok) {
|
||||
throw new Error(`Could not get custom token: ${await result.text()}`)
|
||||
}
|
||||
return (await result.json()) as { token: string }
|
||||
}
|
||||
|
||||
type RequestContext = {
|
||||
|
@ -41,39 +71,103 @@ type RequestContext = {
|
|||
res: ServerResponse
|
||||
}
|
||||
|
||||
export const getServerAuthenticatedUid = async (ctx: RequestContext) => {
|
||||
const app = await ensureApp()
|
||||
const auth = app.auth()
|
||||
const { idToken, refreshToken } = getAuthCookies(ctx.req)
|
||||
const authAndRefreshTokens = async (ctx: RequestContext) => {
|
||||
const adminAuth = (await ensureApp()).auth()
|
||||
const clientAuth = getAuth(clientApp)
|
||||
let { id, refresh, custom } = getTokensFromCookies(ctx.req)
|
||||
|
||||
// If we have a valid ID token, verify the user immediately with no network trips.
|
||||
// If the ID token doesn't verify, we'll have to refresh it to see who they are.
|
||||
// If they don't have any tokens, then we have no idea who they are.
|
||||
if (idToken != null) {
|
||||
// step 0: if you have no refresh token you are logged out
|
||||
if (refresh == null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// step 1: given a valid refresh token, ensure a valid ID token
|
||||
if (id != null) {
|
||||
// if they have an ID token, throw it out if it's invalid/expired
|
||||
try {
|
||||
return (await auth.verifyIdToken(idToken))?.uid
|
||||
await adminAuth.verifyIdToken(id)
|
||||
} catch {
|
||||
// plausibly expired; try the refresh token, if it's present
|
||||
id = undefined
|
||||
}
|
||||
}
|
||||
if (refreshToken != null) {
|
||||
if (id == null) {
|
||||
// ask for a new one from google using the refresh token
|
||||
try {
|
||||
const resp = await requestFirebaseIdToken(refreshToken)
|
||||
setAuthCookies(resp.id_token, resp.refresh_token, ctx.res)
|
||||
return (await auth.verifyIdToken(resp.id_token))?.uid
|
||||
const resp = await requestFirebaseIdToken(refresh)
|
||||
id = resp.id_token
|
||||
refresh = resp.refresh_token
|
||||
} catch (e) {
|
||||
// this is a big unexpected problem -- either their cookies are corrupt
|
||||
// or the refresh token API is down. functionally, they are not logged in
|
||||
// big unexpected problem -- functionally, they are not logged in
|
||||
console.error(e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// step 2: given a valid ID token, ensure a valid custom token, and sign in
|
||||
// to the client SDK with the custom token
|
||||
if (custom != null) {
|
||||
// sign in with this token, or throw it out if it's invalid/expired
|
||||
try {
|
||||
return {
|
||||
creds: await signInWithCustomToken(clientAuth, custom),
|
||||
id,
|
||||
refresh,
|
||||
custom,
|
||||
}
|
||||
} catch {
|
||||
custom = undefined
|
||||
}
|
||||
}
|
||||
if (custom == null) {
|
||||
// ask for a new one from our cloud functions using the ID token, then sign in
|
||||
try {
|
||||
const resp = await requestManifoldCustomToken(id)
|
||||
custom = resp.token
|
||||
return {
|
||||
creds: await signInWithCustomToken(clientAuth, custom),
|
||||
id,
|
||||
refresh,
|
||||
custom,
|
||||
}
|
||||
} catch (e) {
|
||||
// big unexpected problem -- functionally, they are not logged in
|
||||
console.error(e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => {
|
||||
export const authenticateOnServer = async (ctx: RequestContext) => {
|
||||
const tokens = await authAndRefreshTokens(ctx)
|
||||
const creds = tokens?.creds
|
||||
try {
|
||||
if (tokens == null) {
|
||||
deleteTokenCookies(ctx.res)
|
||||
} else {
|
||||
setTokenCookies(tokens, ctx.res)
|
||||
}
|
||||
} catch (e) {
|
||||
// definitely not supposed to happen, but let's be maximally robust
|
||||
console.error(e)
|
||||
}
|
||||
return creds
|
||||
}
|
||||
|
||||
// note that we might want to define these types more generically if we want better
|
||||
// type safety on next.js stuff... see the definition of GetServerSideProps
|
||||
|
||||
type GetServerSidePropsAuthed<P> = (
|
||||
context: GetServerSidePropsContext,
|
||||
creds: UserCredential
|
||||
) => Promise<GetServerSidePropsResult<P>>
|
||||
|
||||
export const redirectIfLoggedIn = <P>(
|
||||
dest: string,
|
||||
fn?: GetServerSideProps<P>
|
||||
) => {
|
||||
return async (ctx: GetServerSidePropsContext) => {
|
||||
const uid = await getServerAuthenticatedUid(ctx)
|
||||
if (uid == null) {
|
||||
const creds = await authenticateOnServer(ctx)
|
||||
if (creds == null) {
|
||||
return fn != null ? await fn(ctx) : { props: {} }
|
||||
} else {
|
||||
return { redirect: { destination: dest, permanent: false } }
|
||||
|
@ -81,13 +175,16 @@ export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => {
|
|||
}
|
||||
}
|
||||
|
||||
export const redirectIfLoggedOut = (dest: string, fn?: GetServerSideProps) => {
|
||||
export const redirectIfLoggedOut = <P>(
|
||||
dest: string,
|
||||
fn?: GetServerSidePropsAuthed<P>
|
||||
) => {
|
||||
return async (ctx: GetServerSidePropsContext) => {
|
||||
const uid = await getServerAuthenticatedUid(ctx)
|
||||
if (uid == null) {
|
||||
const creds = await authenticateOnServer(ctx)
|
||||
if (creds == null) {
|
||||
return { redirect: { destination: dest, permanent: false } }
|
||||
} else {
|
||||
return fn != null ? await fn(ctx) : { props: {} }
|
||||
return fn != null ? await fn(ctx, creds) : { props: {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,6 +52,11 @@ export async function getUser(userId: string) {
|
|||
return (await getDoc(doc(users, userId))).data()!
|
||||
}
|
||||
|
||||
export async function getPrivateUser(userId: string) {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
return (await getDoc(doc(users, userId))).data()!
|
||||
}
|
||||
|
||||
export async function getUserByUsername(username: string) {
|
||||
// Find a user whose username matches the given username, or null if no such user exists.
|
||||
const q = query(users, where('username', '==', username), limit(1))
|
||||
|
@ -96,22 +101,25 @@ const CACHED_REFERRAL_GROUP_ID_KEY = 'CACHED_REFERRAL_GROUP_KEY'
|
|||
|
||||
export function writeReferralInfo(
|
||||
defaultReferrerUsername: string,
|
||||
contractId?: string,
|
||||
referralUsername?: string,
|
||||
groupId?: string
|
||||
otherOptions?: {
|
||||
contractId?: string
|
||||
overwriteReferralUsername?: string
|
||||
groupId?: string
|
||||
}
|
||||
) {
|
||||
const local = safeLocalStorage()
|
||||
const cachedReferralUser = local?.getItem(CACHED_REFERRAL_USERNAME_KEY)
|
||||
const { contractId, overwriteReferralUsername, groupId } = otherOptions || {}
|
||||
// Write the first referral username we see.
|
||||
if (!cachedReferralUser)
|
||||
local?.setItem(
|
||||
CACHED_REFERRAL_USERNAME_KEY,
|
||||
referralUsername || defaultReferrerUsername
|
||||
overwriteReferralUsername || defaultReferrerUsername
|
||||
)
|
||||
|
||||
// If an explicit referral query is passed, overwrite the cached referral username.
|
||||
if (referralUsername)
|
||||
local?.setItem(CACHED_REFERRAL_USERNAME_KEY, referralUsername)
|
||||
if (overwriteReferralUsername)
|
||||
local?.setItem(CACHED_REFERRAL_USERNAME_KEY, overwriteReferralUsername)
|
||||
|
||||
// Always write the most recent explicit group invite query value
|
||||
if (groupId) local?.setItem(CACHED_REFERRAL_GROUP_ID_KEY, groupId)
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowLeftIcon } from '@heroicons/react/outline'
|
||||
import { groupBy, keyBy, mapValues, sortBy, sumBy } from 'lodash'
|
||||
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
import { ContractOverview } from 'web/components/contract/contract-overview'
|
||||
import { BetPanel } from 'web/components/bet-panel'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||
import { ResolutionPanel } from 'web/components/resolution-panel'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import {
|
||||
Contract,
|
||||
getContractFromSlug,
|
||||
tradingAllowed,
|
||||
getBinaryProbPercent,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { Page } from 'web/components/page'
|
||||
|
@ -21,26 +21,29 @@ import { Comment, listAllComments } from 'web/lib/firebase/comments'
|
|||
import Custom404 from '../404'
|
||||
import { AnswersPanel } from 'web/components/answers/answers-panel'
|
||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||
import { Leaderboard } from 'web/components/leaderboard'
|
||||
import { resolvedPayout } from 'common/calculate'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { ContractTabs } from 'web/components/contract/contract-tabs'
|
||||
import { contractTextDetails } from 'web/components/contract/contract-details'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import Confetti from 'react-confetti'
|
||||
import { NumericBetPanel } from '../../components/numeric-bet-panel'
|
||||
import { NumericResolutionPanel } from '../../components/numeric-resolution-panel'
|
||||
import { NumericBetPanel } from 'web/components/numeric-bet-panel'
|
||||
import { NumericResolutionPanel } from 'web/components/numeric-resolution-panel'
|
||||
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
||||
import ContractEmbedPage from '../embed/[username]/[contractSlug]'
|
||||
import { useBets } from 'web/hooks/use-bets'
|
||||
import { CPMMBinaryContract } from 'common/contract'
|
||||
import { AlertBox } from 'web/components/alert-box'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { useLiquidity } from 'web/hooks/use-liquidity'
|
||||
import { richTextToString } from 'common/util/parse'
|
||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import {
|
||||
ContractLeaderboard,
|
||||
ContractTopTrades,
|
||||
} from 'web/components/contract/contract-leaderboard'
|
||||
import { getOpenGraphProps } from 'web/components/contract/contract-card-preview'
|
||||
import { User } from 'common/user'
|
||||
import { listUsers } from 'web/lib/firebase/users'
|
||||
import { FeedComment } from 'web/components/feed/feed-comments'
|
||||
import { Title } from 'web/components/title'
|
||||
import { FeedBet } from 'web/components/feed/feed-bets'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: {
|
||||
|
@ -153,7 +156,7 @@ export function ContractPageContent(
|
|||
const ogCardProps = getOpenGraphProps(contract)
|
||||
|
||||
useSaveReferral(user, {
|
||||
defaultReferrer: contract.creatorUsername,
|
||||
defaultReferrerUsername: contract.creatorUsername,
|
||||
contractId: contract.id,
|
||||
})
|
||||
|
||||
|
@ -208,7 +211,10 @@ export function ContractPageContent(
|
|||
</button>
|
||||
)}
|
||||
|
||||
<ContractOverview contract={contract} bets={bets} />
|
||||
<ContractOverview
|
||||
contract={contract}
|
||||
bets={bets.filter((b) => !b.challengeSlug)}
|
||||
/>
|
||||
|
||||
{isNumeric && (
|
||||
<AlertBox
|
||||
|
@ -258,34 +264,124 @@ export function ContractPageContent(
|
|||
)
|
||||
}
|
||||
|
||||
const getOpenGraphProps = (contract: Contract) => {
|
||||
const {
|
||||
resolution,
|
||||
question,
|
||||
creatorName,
|
||||
creatorUsername,
|
||||
outcomeType,
|
||||
creatorAvatarUrl,
|
||||
description: desc,
|
||||
} = contract
|
||||
const probPercent =
|
||||
outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined
|
||||
function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
|
||||
const { contract, bets } = props
|
||||
const [users, setUsers] = useState<User[]>()
|
||||
|
||||
const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc)
|
||||
const { userProfits, top5Ids } = useMemo(() => {
|
||||
// Create a map of userIds to total profits (including sales)
|
||||
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
|
||||
const betsByUser = groupBy(openBets, 'userId')
|
||||
|
||||
const description = resolution
|
||||
? `Resolved ${resolution}. ${stringDesc}`
|
||||
: probPercent
|
||||
? `${probPercent} chance. ${stringDesc}`
|
||||
: stringDesc
|
||||
const userProfits = mapValues(betsByUser, (bets) =>
|
||||
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
|
||||
)
|
||||
// Find the 5 users with the most profits
|
||||
const top5Ids = Object.entries(userProfits)
|
||||
.sort(([_i1, p1], [_i2, p2]) => p2 - p1)
|
||||
.filter(([, p]) => p > 0)
|
||||
.slice(0, 5)
|
||||
.map(([id]) => id)
|
||||
return { userProfits, top5Ids }
|
||||
}, [contract, bets])
|
||||
|
||||
return {
|
||||
question,
|
||||
probability: probPercent,
|
||||
metadata: contractTextDetails(contract),
|
||||
creatorName,
|
||||
creatorUsername,
|
||||
creatorAvatarUrl,
|
||||
description,
|
||||
}
|
||||
useEffect(() => {
|
||||
if (top5Ids.length > 0) {
|
||||
listUsers(top5Ids).then((users) => {
|
||||
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
|
||||
setUsers(sortedUsers)
|
||||
})
|
||||
}
|
||||
}, [userProfits, top5Ids])
|
||||
|
||||
return users && users.length > 0 ? (
|
||||
<Leaderboard
|
||||
title="🏅 Top bettors"
|
||||
users={users || []}
|
||||
columns={[
|
||||
{
|
||||
header: 'Total profit',
|
||||
renderCell: (user) => formatMoney(userProfits[user.id] || 0),
|
||||
},
|
||||
]}
|
||||
className="mt-12 max-w-sm"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
function ContractTopTrades(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, bets, comments, tips } = props
|
||||
const commentsById = keyBy(comments, 'id')
|
||||
const betsById = keyBy(bets, 'id')
|
||||
|
||||
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
|
||||
// Otherwise, we record the profit at resolution time
|
||||
const profitById: Record<string, number> = {}
|
||||
for (const bet of bets) {
|
||||
if (bet.sale) {
|
||||
const originalBet = betsById[bet.sale.betId]
|
||||
const profit = bet.sale.amount - originalBet.amount
|
||||
profitById[bet.id] = profit
|
||||
profitById[originalBet.id] = profit
|
||||
} else {
|
||||
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
|
||||
}
|
||||
}
|
||||
|
||||
// Now find the betId with the highest profit
|
||||
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
|
||||
const topBettor = useUserById(betsById[topBetId]?.userId)
|
||||
|
||||
// And also the commentId of the comment with the highest profit
|
||||
const topCommentId = sortBy(
|
||||
comments,
|
||||
(c) => c.betId && -profitById[c.betId]
|
||||
)[0]?.id
|
||||
|
||||
return (
|
||||
<div className="mt-12 max-w-sm">
|
||||
{topCommentId && profitById[topCommentId] > 0 && (
|
||||
<>
|
||||
<Title text="💬 Proven correct" className="!mt-0" />
|
||||
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||
<FeedComment
|
||||
contract={contract}
|
||||
comment={commentsById[topCommentId]}
|
||||
tips={tips[topCommentId]}
|
||||
betsBySameUser={[betsById[topCommentId]]}
|
||||
smallAvatar={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
{commentsById[topCommentId].userName} made{' '}
|
||||
{formatMoney(profitById[topCommentId] || 0)}!
|
||||
</div>
|
||||
<Spacer h={16} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* If they're the same, only show the comment; otherwise show both */}
|
||||
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
|
||||
<>
|
||||
<Title text="💸 Smartest money" className="!mt-0" />
|
||||
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||
<FeedBet
|
||||
contract={contract}
|
||||
bet={betsById[topBetId]}
|
||||
hideOutcome={false}
|
||||
smallAvatar={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import Script from 'next/script'
|
|||
import { usePreserveScroll } from 'web/hooks/use-preserve-scroll'
|
||||
import { QueryClient, QueryClientProvider } from 'react-query'
|
||||
import { AuthProvider } from 'web/components/auth-context'
|
||||
import Welcome from 'web/components/onboarding/welcome'
|
||||
|
||||
function firstLine(msg: string) {
|
||||
return msg.replace(/\r?\n.*/s, '')
|
||||
|
@ -78,9 +79,9 @@ function MyApp({ Component, pageProps }: AppProps) {
|
|||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<AuthProvider>
|
||||
<AuthProvider serverUser={pageProps.user}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Welcome {...pageProps} />
|
||||
<Component {...pageProps} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user