Merge branch 'main' into new-challenge
This commit is contained in:
commit
4aa61faa19
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 }}
|
|
@ -14,19 +14,21 @@ export type Bet = {
|
||||||
probBefore: number
|
probBefore: number
|
||||||
probAfter: number
|
probAfter: number
|
||||||
|
|
||||||
sale?: {
|
|
||||||
amount: number // amount user makes from sale
|
|
||||||
betId: string // id of bet being sold
|
|
||||||
// TODO: add sale time?
|
|
||||||
}
|
|
||||||
|
|
||||||
fees: Fees
|
fees: Fees
|
||||||
|
|
||||||
isSold?: boolean // true if this BUY bet has been sold
|
|
||||||
isAnte?: boolean
|
isAnte?: boolean
|
||||||
isLiquidityProvision?: boolean
|
isLiquidityProvision?: boolean
|
||||||
isRedemption?: boolean
|
isRedemption?: boolean
|
||||||
challengeSlug?: string
|
challengeSlug?: string
|
||||||
|
|
||||||
|
// Props for bets in DPM contract below.
|
||||||
|
// A bet is either a BUY or a SELL that sells all of a previous buy.
|
||||||
|
isSold?: boolean // true if this BUY bet has been sold
|
||||||
|
// This field marks a SELL bet.
|
||||||
|
sale?: {
|
||||||
|
amount: number // amount user makes from sale
|
||||||
|
betId: string // id of BUY bet being sold
|
||||||
|
}
|
||||||
} & Partial<LimitProps>
|
} & Partial<LimitProps>
|
||||||
|
|
||||||
export type NumericBet = Bet & {
|
export type NumericBet = Bet & {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { maxBy } from 'lodash'
|
import { maxBy, sortBy, sum, sumBy } from 'lodash'
|
||||||
import { Bet, LimitBet } from './bet'
|
import { Bet, LimitBet } from './bet'
|
||||||
import {
|
import {
|
||||||
calculateCpmmSale,
|
calculateCpmmSale,
|
||||||
|
@ -133,10 +133,46 @@ export function resolvedPayout(contract: Contract, bet: Bet) {
|
||||||
: calculateDpmPayout(contract, bet, outcome)
|
: calculateDpmPayout(contract, bet, outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCpmmInvested(yourBets: Bet[]) {
|
||||||
|
const totalShares: { [outcome: string]: number } = {}
|
||||||
|
const totalSpent: { [outcome: string]: number } = {}
|
||||||
|
|
||||||
|
const sortedBets = sortBy(yourBets, 'createdTime')
|
||||||
|
for (const bet of sortedBets) {
|
||||||
|
const { outcome, shares, amount } = bet
|
||||||
|
if (amount > 0) {
|
||||||
|
totalShares[outcome] = (totalShares[outcome] ?? 0) + shares
|
||||||
|
totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount
|
||||||
|
} else if (amount < 0) {
|
||||||
|
const averagePrice = totalSpent[outcome] / totalShares[outcome]
|
||||||
|
totalShares[outcome] = totalShares[outcome] + shares
|
||||||
|
totalSpent[outcome] = totalSpent[outcome] + averagePrice * shares
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum(Object.values(totalSpent))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDpmInvested(yourBets: Bet[]) {
|
||||||
|
const sortedBets = sortBy(yourBets, 'createdTime')
|
||||||
|
|
||||||
|
return sumBy(sortedBets, (bet) => {
|
||||||
|
const { amount, sale } = bet
|
||||||
|
|
||||||
|
if (sale) {
|
||||||
|
const originalBet = sortedBets.find((b) => b.id === sale.betId)
|
||||||
|
if (originalBet) return -originalBet.amount
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return amount
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
||||||
const { resolution } = contract
|
const { resolution } = contract
|
||||||
|
const isCpmm = contract.mechanism === 'cpmm-1'
|
||||||
|
|
||||||
let currentInvested = 0
|
|
||||||
let totalInvested = 0
|
let totalInvested = 0
|
||||||
let payout = 0
|
let payout = 0
|
||||||
let loan = 0
|
let loan = 0
|
||||||
|
@ -162,7 +198,6 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
||||||
saleValue -= amount
|
saleValue -= amount
|
||||||
}
|
}
|
||||||
|
|
||||||
currentInvested += amount
|
|
||||||
loan += loanAmount ?? 0
|
loan += loanAmount ?? 0
|
||||||
payout += resolution
|
payout += resolution
|
||||||
? calculatePayout(contract, bet, resolution)
|
? calculatePayout(contract, bet, resolution)
|
||||||
|
@ -174,12 +209,13 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
||||||
const profit = payout + saleValue + redeemed - totalInvested
|
const profit = payout + saleValue + redeemed - totalInvested
|
||||||
const profitPercent = (profit / totalInvested) * 100
|
const profitPercent = (profit / totalInvested) * 100
|
||||||
|
|
||||||
|
const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets)
|
||||||
const hasShares = Object.values(totalShares).some(
|
const hasShares = Object.values(totalShares).some(
|
||||||
(shares) => !floatingEqual(shares, 0)
|
(shares) => !floatingEqual(shares, 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invested: Math.max(0, currentInvested),
|
invested,
|
||||||
payout,
|
payout,
|
||||||
netPayout,
|
netPayout,
|
||||||
profit,
|
profit,
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { IS_PRIVATE_MANIFOLD } from './envs/constants'
|
||||||
|
|
||||||
export type Challenge = {
|
export type Challenge = {
|
||||||
// The link to send: https://manifold.markets/challenges/username/market-slug/{slug}
|
// The link to send: https://manifold.markets/challenges/username/market-slug/{slug}
|
||||||
// Also functions as the unique id for the link.
|
// Also functions as the unique id for the link.
|
||||||
|
@ -60,4 +62,4 @@ export type Acceptance = {
|
||||||
createdTime: number
|
createdTime: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CHALLENGES_ENABLED = true
|
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.
|
// Currently, comments are created after the bet, not atomically with the bet.
|
||||||
// They're uniquely identified by the pair contractId/betId.
|
// They're uniquely identified by the pair contractId/betId.
|
||||||
export type Comment = {
|
export type Comment = {
|
||||||
|
@ -9,11 +11,15 @@ export type Comment = {
|
||||||
replyToCommentId?: string
|
replyToCommentId?: string
|
||||||
userId: string
|
userId: string
|
||||||
|
|
||||||
text: string
|
/** @deprecated - content now stored as JSON in content*/
|
||||||
|
text?: string
|
||||||
|
content: JSONContent
|
||||||
createdTime: number
|
createdTime: number
|
||||||
|
|
||||||
// Denormalized, for rendering comments
|
// Denormalized, for rendering comments
|
||||||
userName: string
|
userName: string
|
||||||
userUsername: string
|
userUsername: string
|
||||||
userAvatarUrl?: string
|
userAvatarUrl?: string
|
||||||
|
contractSlug?: string
|
||||||
|
contractQuestion?: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -139,7 +139,7 @@ export const OUTCOME_TYPES = [
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const MAX_QUESTION_LENGTH = 480
|
export const MAX_QUESTION_LENGTH = 480
|
||||||
export const MAX_DESCRIPTION_LENGTH = 10000
|
export const MAX_DESCRIPTION_LENGTH = 16000
|
||||||
export const MAX_TAG_LENGTH = 60
|
export const MAX_TAG_LENGTH = 60
|
||||||
|
|
||||||
export const CPMM_MIN_POOL_QTY = 0.01
|
export const CPMM_MIN_POOL_QTY = 0.01
|
||||||
|
|
|
@ -25,6 +25,10 @@ export function isAdmin(email: string) {
|
||||||
return ENV_CONFIG.adminEmails.includes(email)
|
return ENV_CONFIG.adminEmails.includes(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isManifoldId(userId: string) {
|
||||||
|
return userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2'
|
||||||
|
}
|
||||||
|
|
||||||
export const DOMAIN = ENV_CONFIG.domain
|
export const DOMAIN = ENV_CONFIG.domain
|
||||||
export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
|
export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
|
||||||
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
|
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { EnvConfig, PROD_CONFIG } from './prod'
|
||||||
|
|
||||||
export const DEV_CONFIG: EnvConfig = {
|
export const DEV_CONFIG: EnvConfig = {
|
||||||
...PROD_CONFIG,
|
...PROD_CONFIG,
|
||||||
|
domain: 'dev.manifold.markets',
|
||||||
firebaseConfig: {
|
firebaseConfig: {
|
||||||
apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw',
|
apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw',
|
||||||
authDomain: 'dev-mantic-markets.firebaseapp.com',
|
authDomain: 'dev-mantic-markets.firebaseapp.com',
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/core": "2.0.0-beta.181",
|
||||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||||
|
|
|
@ -1,3 +1,40 @@
|
||||||
|
import { isEqual } from 'lodash'
|
||||||
|
|
||||||
export function filterDefined<T>(array: (T | null | undefined)[]) {
|
export function filterDefined<T>(array: (T | null | undefined)[]) {
|
||||||
return array.filter((item) => item !== null && item !== undefined) as T[]
|
return array.filter((item) => item !== null && item !== undefined) as T[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildArray<T>(
|
||||||
|
...params: (T | T[] | false | undefined | null)[]
|
||||||
|
) {
|
||||||
|
const array: T[] = []
|
||||||
|
|
||||||
|
for (const el of params) {
|
||||||
|
if (Array.isArray(el)) {
|
||||||
|
array.push(...el)
|
||||||
|
} else if (el) {
|
||||||
|
array.push(el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupConsecutive<T, U>(xs: T[], key: (x: T) => U) {
|
||||||
|
if (!xs.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const result = []
|
||||||
|
let curr = { key: key(xs[0]), items: [xs[0]] }
|
||||||
|
for (const x of xs.slice(1)) {
|
||||||
|
const k = key(x)
|
||||||
|
if (!isEqual(key, curr.key)) {
|
||||||
|
result.push(curr)
|
||||||
|
curr = { key: k, items: [x] }
|
||||||
|
} else {
|
||||||
|
curr.items.push(x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(curr)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ import { Image } from '@tiptap/extension-image'
|
||||||
import { Link } from '@tiptap/extension-link'
|
import { Link } from '@tiptap/extension-link'
|
||||||
import { Mention } from '@tiptap/extension-mention'
|
import { Mention } from '@tiptap/extension-mention'
|
||||||
import Iframe from './tiptap-iframe'
|
import Iframe from './tiptap-iframe'
|
||||||
|
import TiptapTweet from './tiptap-tweet-type'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
|
|
||||||
export function parseTags(text: string) {
|
export function parseTags(text: string) {
|
||||||
|
@ -94,6 +95,7 @@ export const exhibitExts = [
|
||||||
Link,
|
Link,
|
||||||
Mention,
|
Mention,
|
||||||
Iframe,
|
Iframe,
|
||||||
|
TiptapTweet,
|
||||||
]
|
]
|
||||||
|
|
||||||
export function richTextToString(text?: JSONContent) {
|
export function richTextToString(text?: JSONContent) {
|
||||||
|
|
37
common/util/tiptap-tweet-type.ts
Normal file
37
common/util/tiptap-tweet-type.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
|
|
||||||
|
export interface TweetOptions {
|
||||||
|
tweetId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a version of the Tiptap Node config without addNodeView,
|
||||||
|
// since that would require bundling in tsx
|
||||||
|
export const TiptapTweetNode = {
|
||||||
|
name: 'tiptapTweet',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
tweetId: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'tiptap-tweet',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML(props: { HTMLAttributes: Record<string, any> }) {
|
||||||
|
return ['tiptap-tweet', mergeAttributes(props.HTMLAttributes)]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
export default Node.create<TweetOptions>(TiptapTweetNode)
|
|
@ -135,7 +135,8 @@ Requires no authorization.
|
||||||
// Market attributes. All times are in milliseconds since epoch
|
// Market attributes. All times are in milliseconds since epoch
|
||||||
closeTime?: number // Min of creator's chosen date, and resolutionTime
|
closeTime?: number // Min of creator's chosen date, and resolutionTime
|
||||||
question: string
|
question: string
|
||||||
description: string
|
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
|
||||||
|
textDescription: string // string description without formatting, images, or embeds
|
||||||
|
|
||||||
// A list of tags on each market. Any user can add tags to any market.
|
// A list of tags on each market. Any user can add tags to any market.
|
||||||
// This list also includes the predefined categories shown as filters on the home page.
|
// This list also includes the predefined categories shown as filters on the home page.
|
||||||
|
@ -162,6 +163,8 @@ Requires no authorization.
|
||||||
resolutionTime?: number
|
resolutionTime?: number
|
||||||
resolution?: string
|
resolution?: string
|
||||||
resolutionProbability?: number // Used for BINARY markets resolved to MKT
|
resolutionProbability?: number // Used for BINARY markets resolved to MKT
|
||||||
|
|
||||||
|
lastUpdatedTime?: number
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -528,6 +531,10 @@ $ curl https://manifold.markets/api/v0/bet -X POST -H 'Content-Type: application
|
||||||
"contractId":"{...}"}'
|
"contractId":"{...}"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `POST /v0/bet/cancel/[id]`
|
||||||
|
|
||||||
|
Cancel the limit order of a bet with the specified id. If the bet was unfilled, it will be cancelled so that no other bets will match with it. This is action irreversable.
|
||||||
|
|
||||||
### `POST /v0/market`
|
### `POST /v0/market`
|
||||||
|
|
||||||
Creates a new market on behalf of the authorized user.
|
Creates a new market on behalf of the authorized user.
|
||||||
|
@ -537,6 +544,7 @@ Parameters:
|
||||||
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`.
|
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`.
|
||||||
- `question`: Required. The headline question for the market.
|
- `question`: Required. The headline question for the market.
|
||||||
- `description`: Required. A long description describing the rules for the market.
|
- `description`: Required. A long description describing the rules for the market.
|
||||||
|
- Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json).
|
||||||
- `closeTime`: Required. The time at which the market will close, represented as milliseconds since the epoch.
|
- `closeTime`: Required. The time at which the market will close, represented as milliseconds since the epoch.
|
||||||
- `tags`: Optional. An array of string tags for the market.
|
- `tags`: Optional. An array of string tags for the market.
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,8 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@docusaurus/module-type-aliases": "2.0.0-beta.17",
|
"@docusaurus/module-type-aliases": "2.0.0-beta.17",
|
||||||
"@tsconfig/docusaurus": "^1.0.4"
|
"@tsconfig/docusaurus": "^1.0.4",
|
||||||
|
"@types/react": "^17.0.2"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|
|
@ -496,6 +496,28 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "comments",
|
||||||
|
"fieldPath": "contractId",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"order": "ASCENDING",
|
||||||
|
"queryScope": "COLLECTION"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": "DESCENDING",
|
||||||
|
"queryScope": "COLLECTION"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arrayConfig": "CONTAINS",
|
||||||
|
"queryScope": "COLLECTION"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": "ASCENDING",
|
||||||
|
"queryScope": "COLLECTION_GROUP"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"collectionGroup": "comments",
|
"collectionGroup": "comments",
|
||||||
"fieldPath": "createdTime",
|
"fieldPath": "createdTime",
|
||||||
|
|
|
@ -20,17 +20,17 @@ service cloud.firestore {
|
||||||
|
|
||||||
match /users/{userId} {
|
match /users/{userId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if resource.data.id == request.auth.uid
|
allow update: if userId == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']);
|
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']);
|
||||||
// User referral rules
|
// User referral rules
|
||||||
allow update: if resource.data.id == request.auth.uid
|
allow update: if userId == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['referredByUserId', 'referredByContractId', 'referredByGroupId'])
|
.hasOnly(['referredByUserId', 'referredByContractId', 'referredByGroupId'])
|
||||||
// only one referral allowed per user
|
// only one referral allowed per user
|
||||||
&& !("referredByUserId" in resource.data)
|
&& !("referredByUserId" in resource.data)
|
||||||
// user can't refer themselves
|
// user can't refer themselves
|
||||||
&& !(resource.data.id == request.resource.data.referredByUserId);
|
&& !(userId == request.resource.data.referredByUserId);
|
||||||
// quid pro quos enabled (only once though so nbd) - bc I can't make this work:
|
// quid pro quos enabled (only once though so nbd) - bc I can't make this work:
|
||||||
// && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id);
|
// && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id);
|
||||||
}
|
}
|
||||||
|
@ -60,8 +60,8 @@ service cloud.firestore {
|
||||||
}
|
}
|
||||||
|
|
||||||
match /private-users/{userId} {
|
match /private-users/{userId} {
|
||||||
allow read: if resource.data.id == request.auth.uid || isAdmin();
|
allow read: if userId == request.auth.uid || isAdmin();
|
||||||
allow update: if (resource.data.id == request.auth.uid || isAdmin())
|
allow update: if (userId == request.auth.uid || isAdmin())
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences' ]);
|
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences' ]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,8 +31,8 @@
|
||||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||||
"dayjs": "1.11.4",
|
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
|
"dayjs": "1.11.4",
|
||||||
"express": "4.18.1",
|
"express": "4.18.1",
|
||||||
"firebase-admin": "10.0.0",
|
"firebase-admin": "10.0.0",
|
||||||
"firebase-functions": "3.21.2",
|
"firebase-functions": "3.21.2",
|
||||||
|
|
|
@ -3,7 +3,7 @@ import * as Amplitude from '@amplitude/node'
|
||||||
import { DEV_CONFIG } from '../../common/envs/dev'
|
import { DEV_CONFIG } from '../../common/envs/dev'
|
||||||
import { PROD_CONFIG } from '../../common/envs/prod'
|
import { PROD_CONFIG } from '../../common/envs/prod'
|
||||||
|
|
||||||
import { isProd } from './utils'
|
import { isProd, tryOrLogError } from './utils'
|
||||||
|
|
||||||
const key = isProd() ? PROD_CONFIG.amplitudeApiKey : DEV_CONFIG.amplitudeApiKey
|
const key = isProd() ? PROD_CONFIG.amplitudeApiKey : DEV_CONFIG.amplitudeApiKey
|
||||||
|
|
||||||
|
@ -15,10 +15,12 @@ export const track = async (
|
||||||
eventProperties?: any,
|
eventProperties?: any,
|
||||||
amplitudeProperties?: Partial<Amplitude.Event>
|
amplitudeProperties?: Partial<Amplitude.Event>
|
||||||
) => {
|
) => {
|
||||||
await amp.logEvent({
|
return await tryOrLogError(
|
||||||
|
amp.logEvent({
|
||||||
event_type: eventName,
|
event_type: eventName,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
event_properties: eventProperties,
|
event_properties: eventProperties,
|
||||||
...amplitudeProperties,
|
...amplitudeProperties,
|
||||||
})
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = () => {
|
export const zTimestamp = () => {
|
||||||
return z.preprocess((arg) => {
|
return z.preprocess((arg) => {
|
||||||
return typeof arg == 'number' ? new Date(arg) : undefined
|
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))
|
const authedUser = await lookupUser(await parseCredentials(req))
|
||||||
res.status(200).json(await fn(req, authedUser))
|
res.status(200).json(await fn(req, authedUser))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof APIError) {
|
writeResponseError(e, res)
|
||||||
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.' })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
} as EndpointDefinition
|
} as EndpointDefinition
|
||||||
|
|
|
@ -59,7 +59,7 @@ const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
question: z.string().min(1).max(MAX_QUESTION_LENGTH),
|
question: z.string().min(1).max(MAX_QUESTION_LENGTH),
|
||||||
description: descScehma.optional(),
|
description: descScehma.or(z.string()).optional(),
|
||||||
tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(),
|
tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(),
|
||||||
closeTime: zTimestamp().refine(
|
closeTime: zTimestamp().refine(
|
||||||
(date) => date.getTime() > new Date().getTime(),
|
(date) => date.getTime() > new Date().getTime(),
|
||||||
|
@ -133,41 +133,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
if (ante > user.balance)
|
if (ante > user.balance)
|
||||||
throw new APIError(400, `Balance must be at least ${ante}.`)
|
throw new APIError(400, `Balance must be at least ${ante}.`)
|
||||||
|
|
||||||
const slug = await getSlug(question)
|
let group: Group | null = null
|
||||||
const contractRef = firestore.collection('contracts').doc()
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
'creating contract for',
|
|
||||||
user.username,
|
|
||||||
'on',
|
|
||||||
question,
|
|
||||||
'ante:',
|
|
||||||
ante || 0
|
|
||||||
)
|
|
||||||
|
|
||||||
const contract = getNewContract(
|
|
||||||
contractRef.id,
|
|
||||||
slug,
|
|
||||||
user,
|
|
||||||
question,
|
|
||||||
outcomeType,
|
|
||||||
description ?? {},
|
|
||||||
initialProb ?? 0,
|
|
||||||
ante,
|
|
||||||
closeTime.getTime(),
|
|
||||||
tags ?? [],
|
|
||||||
NUMERIC_BUCKET_COUNT,
|
|
||||||
min ?? 0,
|
|
||||||
max ?? 0,
|
|
||||||
isLogScale ?? false,
|
|
||||||
answers ?? []
|
|
||||||
)
|
|
||||||
|
|
||||||
if (ante) await chargeUser(user.id, ante, true)
|
|
||||||
|
|
||||||
await contractRef.create(contract)
|
|
||||||
|
|
||||||
let group = null
|
|
||||||
if (groupId) {
|
if (groupId) {
|
||||||
const groupDocRef = firestore.collection('groups').doc(groupId)
|
const groupDocRef = firestore.collection('groups').doc(groupId)
|
||||||
const groupDoc = await groupDocRef.get()
|
const groupDoc = await groupDocRef.get()
|
||||||
|
@ -186,9 +152,60 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
'User must be a member/creator of the group or group must be open to add markets to it.'
|
'User must be a member/creator of the group or group must be open to add markets to it.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
const slug = await getSlug(question)
|
||||||
|
const contractRef = firestore.collection('contracts').doc()
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'creating contract for',
|
||||||
|
user.username,
|
||||||
|
'on',
|
||||||
|
question,
|
||||||
|
'ante:',
|
||||||
|
ante || 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// convert string descriptions into JSONContent
|
||||||
|
const newDescription =
|
||||||
|
typeof description === 'string'
|
||||||
|
? {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{ type: 'text', text: description }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: description ?? {}
|
||||||
|
|
||||||
|
const contract = getNewContract(
|
||||||
|
contractRef.id,
|
||||||
|
slug,
|
||||||
|
user,
|
||||||
|
question,
|
||||||
|
outcomeType,
|
||||||
|
newDescription,
|
||||||
|
initialProb ?? 0,
|
||||||
|
ante,
|
||||||
|
closeTime.getTime(),
|
||||||
|
tags ?? [],
|
||||||
|
NUMERIC_BUCKET_COUNT,
|
||||||
|
min ?? 0,
|
||||||
|
max ?? 0,
|
||||||
|
isLogScale ?? false,
|
||||||
|
answers ?? []
|
||||||
|
)
|
||||||
|
|
||||||
|
if (ante) await chargeUser(user.id, ante, true)
|
||||||
|
|
||||||
|
await contractRef.create(contract)
|
||||||
|
|
||||||
|
if (group != null) {
|
||||||
if (!group.contractIds.includes(contractRef.id)) {
|
if (!group.contractIds.includes(contractRef.id)) {
|
||||||
await createGroupLinks(group, [contractRef.id], auth.uid)
|
await createGroupLinks(group, [contractRef.id], auth.uid)
|
||||||
await groupDocRef.update({
|
const groupDocRef = firestore.collection('groups').doc(group.id)
|
||||||
|
groupDocRef.update({
|
||||||
contractIds: uniq([...group.contractIds, contractRef.id]),
|
contractIds: uniq([...group.contractIds, contractRef.id]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
} from '../../common/notification'
|
} from '../../common/notification'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { getUserByUsername, getValues } from './utils'
|
import { getValues } from './utils'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { Bet, LimitBet } from '../../common/bet'
|
import { Bet, LimitBet } from '../../common/bet'
|
||||||
|
@ -17,6 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { TipTxn } from '../../common/txn'
|
import { TipTxn } from '../../common/txn'
|
||||||
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
||||||
import { Challenge } from '../../common/challenge'
|
import { Challenge } from '../../common/challenge'
|
||||||
|
import { richTextToString } from '../../common/util/parse'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
type user_to_reason_texts = {
|
type user_to_reason_texts = {
|
||||||
|
@ -155,17 +156,6 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated parse from rich text instead */
|
|
||||||
const parseMentions = async (source: string) => {
|
|
||||||
const mentions = source.match(/@\w+/g)
|
|
||||||
if (!mentions) return []
|
|
||||||
return Promise.all(
|
|
||||||
mentions.map(
|
|
||||||
async (username) => (await getUserByUsername(username.slice(1)))?.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyTaggedUsers = (
|
const notifyTaggedUsers = (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts,
|
||||||
userIds: (string | undefined)[]
|
userIds: (string | undefined)[]
|
||||||
|
@ -301,8 +291,7 @@ export const createNotification = async (
|
||||||
if (sourceType === 'comment') {
|
if (sourceType === 'comment') {
|
||||||
if (recipients?.[0] && relatedSourceType)
|
if (recipients?.[0] && relatedSourceType)
|
||||||
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
|
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
|
||||||
if (sourceText)
|
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||||
notifyTaggedUsers(userToReasonTexts, await parseMentions(sourceText))
|
|
||||||
}
|
}
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
||||||
|
@ -427,7 +416,7 @@ export const createGroupCommentNotification = async (
|
||||||
sourceUserName: fromUser.name,
|
sourceUserName: fromUser.name,
|
||||||
sourceUserUsername: fromUser.username,
|
sourceUserUsername: fromUser.username,
|
||||||
sourceUserAvatarUrl: fromUser.avatarUrl,
|
sourceUserAvatarUrl: fromUser.avatarUrl,
|
||||||
sourceText: comment.text,
|
sourceText: richTextToString(comment.content),
|
||||||
sourceSlug,
|
sourceSlug,
|
||||||
sourceTitle: `${group.name}`,
|
sourceTitle: `${group.name}`,
|
||||||
isSeenOnHref: sourceSlug,
|
isSeenOnHref: sourceSlug,
|
||||||
|
|
|
@ -16,7 +16,7 @@ import {
|
||||||
cleanDisplayName,
|
cleanDisplayName,
|
||||||
cleanUsername,
|
cleanUsername,
|
||||||
} from '../../common/util/clean-username'
|
} from '../../common/util/clean-username'
|
||||||
import { sendWelcomeEmail } from './emails'
|
import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails'
|
||||||
import { isWhitelisted } from '../../common/envs/constants'
|
import { isWhitelisted } from '../../common/envs/constants'
|
||||||
import {
|
import {
|
||||||
CATEGORIES_GROUP_SLUG_POSTFIX,
|
CATEGORIES_GROUP_SLUG_POSTFIX,
|
||||||
|
@ -96,9 +96,10 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
||||||
|
|
||||||
await addUserToDefaultGroups(user)
|
await addUserToDefaultGroups(user)
|
||||||
await sendWelcomeEmail(user, privateUser)
|
await sendWelcomeEmail(user, privateUser)
|
||||||
|
await sendPersonalFollowupEmail(user, privateUser)
|
||||||
await track(auth.uid, 'create user', { username }, { ip: req.ip })
|
await track(auth.uid, 'create user', { username }, { ip: req.ip })
|
||||||
|
|
||||||
return user
|
return { user, privateUser }
|
||||||
})
|
})
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
|
@ -128,7 +128,20 @@
|
||||||
<div
|
<div
|
||||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
<p class="text-build-content"
|
<p class="text-build-content"
|
||||||
style="line-height: 24px; text-align: center; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
|
data-testid="4XoHRGw1Y"><span
|
||||||
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
|
Hi {{name}},</span></p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<p class="text-build-content"
|
||||||
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
data-testid="4XoHRGw1Y"><span
|
data-testid="4XoHRGw1Y"><span
|
||||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Thanks for
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Thanks for
|
||||||
using Manifold Markets. Running low
|
using Manifold Markets. Running low
|
||||||
|
@ -161,6 +174,51 @@
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;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:Arial, sans-serif;font-size:18px;">Did
|
||||||
|
you know, besides making correct predictions, there are
|
||||||
|
plenty of other ways to earn mana?</span></p>
|
||||||
|
<ul>
|
||||||
|
<li style="line-height:23px;"><span
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;">Receiving
|
||||||
|
tips on comments</span></li>
|
||||||
|
<li style="line-height:23px;"><span
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;">Unique
|
||||||
|
trader bonus for each user who bets on your
|
||||||
|
markets</span></li>
|
||||||
|
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||||
|
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||||
|
target="_blank" href="https://manifold.markets/referrals"><span
|
||||||
|
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Referring
|
||||||
|
friends</u></span></a></span></li>
|
||||||
|
<li style="line-height:23px;"><a class="link-build-content"
|
||||||
|
style="color:inherit;; text-decoration: none;" target="_blank"
|
||||||
|
href="https://manifold.markets/group/bugs?s=most-traded"><span
|
||||||
|
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Reporting
|
||||||
|
bugs</u></span></a><span style="font-family:Arial, sans-serif;font-size:18px;">
|
||||||
|
and </span><a class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||||
|
target="_blank"
|
||||||
|
href="https://manifold.markets/group/manifold-features-25bad7c7792e/chat?s=most-traded"><span
|
||||||
|
style="color:#55575d;font-family:Arial;font-size:18px;"><u>giving
|
||||||
|
feedback</u></span></a></li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p>
|
||||||
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||||
|
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||||
|
style="color:#000000;font-family:Arial;font-size:18px;">David
|
||||||
|
from Manifold</span></p>
|
||||||
|
<p class="text-build-content" data-testid="3Q8BP69fq"
|
||||||
|
style="margin: 10px 0; margin-bottom: 10px;"> </p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left"
|
<td align="left"
|
||||||
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
xmlns="http://www.w3.org/1999/xhtml"
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
xmlns:v="urn:schemas-microsoft-com:vml"
|
|
||||||
xmlns:o="urn:schemas-microsoft-com:office:office"
|
|
||||||
>
|
|
||||||
<head>
|
<head>
|
||||||
<title>(no subject)</title>
|
<title>Manifold Market Creation Guide</title>
|
||||||
<!--[if !mso]><!-->
|
<!--[if !mso]><!-->
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<!--<![endif]-->
|
<!--<![endif]-->
|
||||||
|
@ -15,18 +13,21 @@
|
||||||
#outlook a {
|
#outlook a {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
-ms-text-size-adjust: 100%;
|
-ms-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
table,
|
table,
|
||||||
td {
|
td {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
mso-table-lspace: 0pt;
|
mso-table-lspace: 0pt;
|
||||||
mso-table-rspace: 0pt;
|
mso-table-rspace: 0pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
border: 0;
|
border: 0;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
@ -35,6 +36,7 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
-ms-interpolation-mode: bicubic;
|
-ms-interpolation-mode: bicubic;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 13px 0;
|
margin: 13px 0;
|
||||||
|
@ -58,21 +60,9 @@
|
||||||
</style>
|
</style>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<!--[if !mso]><!-->
|
<!--[if !mso]><!-->
|
||||||
<link
|
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
|
||||||
href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700"
|
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||||
rel="stylesheet"
|
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||||
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">
|
<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=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);
|
||||||
|
@ -104,35 +94,28 @@
|
||||||
table.mj-full-width-mobile {
|
table.mj-full-width-mobile {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
td.mj-full-width-mobile {
|
td.mj-full-width-mobile {
|
||||||
width: auto !important;
|
width: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||||
<div style="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]-->
|
<!--[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
|
<div style="
|
||||||
style="
|
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
margin: 0px auto;
|
margin: 0px auto;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
"
|
">
|
||||||
>
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
<table
|
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||||
align="center"
|
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
style="background: #ffffff; background-color: #ffffff; width: 100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td style="
|
||||||
style="
|
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
padding: 0px 0px 0px 0px;
|
padding: 0px 0px 0px 0px;
|
||||||
|
@ -141,33 +124,21 @@
|
||||||
padding-right: 0px;
|
padding-right: 0px;
|
||||||
padding-top: 0px;
|
padding-top: 0px;
|
||||||
text-align: center;
|
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]-->
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
<div
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||||
class="mj-column-per-100 mj-outlook-group-fix"
|
|
||||||
style="
|
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
"
|
">
|
||||||
>
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align: top"
|
||||||
<table
|
width="100%">
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
style="vertical-align: top"
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="center" style="
|
||||||
align="center"
|
|
||||||
style="
|
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
padding: 0px 25px 0px 25px;
|
padding: 0px 25px 0px 25px;
|
||||||
padding-top: 0px;
|
padding-top: 0px;
|
||||||
|
@ -175,29 +146,16 @@
|
||||||
padding-bottom: 0px;
|
padding-bottom: 0px;
|
||||||
padding-left: 25px;
|
padding-left: 25px;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
"
|
">
|
||||||
>
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="
|
||||||
<table
|
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
style="
|
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
border-spacing: 0px;
|
border-spacing: 0px;
|
||||||
"
|
">
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width: 550px">
|
<td style="width: 550px">
|
||||||
<a
|
<a href="https://manifold.markets/home" target="_blank"><img alt="" height="auto"
|
||||||
href="https://manifold.markets/home"
|
src="https://03jlj.mjt.lu/img/03jlj/b/96u/omk8.gif" style="
|
||||||
target="_blank"
|
|
||||||
><img
|
|
||||||
alt=""
|
|
||||||
height="auto"
|
|
||||||
src="https://03jlj.mjt.lu/img/03jlj/b/96u/omk8.gif"
|
|
||||||
style="
|
|
||||||
border: none;
|
border: none;
|
||||||
display: block;
|
display: block;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
@ -205,9 +163,7 @@
|
||||||
height: auto;
|
height: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
"
|
" width="550" /></a>
|
||||||
width="550"
|
|
||||||
/></a>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -224,26 +180,17 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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]-->
|
<!--[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
|
<div style="
|
||||||
style="
|
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
margin: 0px auto;
|
margin: 0px auto;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
"
|
">
|
||||||
>
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
<table
|
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||||
align="center"
|
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
style="background: #ffffff; background-color: #ffffff; width: 100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td style="
|
||||||
style="
|
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
padding: 20px 0px 0px 0px;
|
padding: 20px 0px 0px 0px;
|
||||||
|
@ -252,33 +199,34 @@
|
||||||
padding-right: 0px;
|
padding-right: 0px;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
text-align: center;
|
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]-->
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
<div
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||||
class="mj-column-per-100 mj-outlook-group-fix"
|
|
||||||
style="
|
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
"
|
">
|
||||||
>
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align: top"
|
||||||
<table
|
width="100%">
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
style="vertical-align: top"
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="left"
|
||||||
align="left"
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
style="
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<p class="text-build-content"
|
||||||
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
|
data-testid="4XoHRGw1Y"><span
|
||||||
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
|
Hi {{name}},</span></p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
padding: 0px 25px 20px 25px;
|
padding: 0px 25px 20px 25px;
|
||||||
padding-top: 0px;
|
padding-top: 0px;
|
||||||
|
@ -286,311 +234,204 @@
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
padding-left: 25px;
|
padding-left: 25px;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
"
|
">
|
||||||
>
|
<div style="
|
||||||
<div
|
|
||||||
style="
|
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
"
|
">
|
||||||
>
|
<p class="text-build-content" style="
|
||||||
<p
|
|
||||||
class="text-build-content"
|
|
||||||
style="
|
|
||||||
line-height: 23px;
|
line-height: 23px;
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
"
|
" data-testid="3Q8BP69fq">
|
||||||
data-testid="3Q8BP69fq"
|
<span style="
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
">Congrats on creating your first market on <a class="link-build-content"
|
||||||
>On Manifold Markets, several important factors
|
style="color: #55575d" target="_blank"
|
||||||
go into making a good question. These lead to
|
href="https://manifold.markets">Manifold</a>!</span>
|
||||||
more people betting on them and allowing a more
|
|
||||||
accurate prediction to be formed!</span
|
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
<p
|
|
||||||
class="text-build-content"
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||||
style="line-height: 23px; margin: 10px 0"
|
data-testid="3Q8BP69fq">
|
||||||
data-testid="3Q8BP69fq"
|
<span style="
|
||||||
>
|
|
||||||
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-build-content"
|
|
||||||
style="line-height: 23px; margin: 10px 0"
|
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
">The following is a short guide to creating markets.</span>
|
||||||
>Manifold also gives its creators 10 Mana for
|
|
||||||
each unique trader that bets on your
|
|
||||||
market!</span
|
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||||
class="text-build-content"
|
data-testid="3Q8BP69fq">
|
||||||
style="line-height: 23px; margin: 10px 0"
|
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
>
|
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||||
class="text-build-content"
|
data-testid="3Q8BP69fq">
|
||||||
style="line-height: 23px; margin: 10px 0"
|
<span style="
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
color: #292fd7;
|
color: #292fd7;
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
"
|
"><b>What makes a good market?</b></span>
|
||||||
><b>What makes a good question?</b></span
|
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li style="line-height: 23px">
|
<li style="line-height: 23px; margin-bottom: 8px;">
|
||||||
<span
|
<span
|
||||||
style="
|
style="font-family: Readex Pro, Arial, Helvetica, sans-serif;font-size: 17px;"><b>Interesting
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
topic. </b>Manifold gives
|
||||||
sans-serif;
|
creators M$10 for
|
||||||
font-size: 17px;
|
each unique trader that bets on your
|
||||||
"
|
market, so it pays to ask a question people are interested in!</span>
|
||||||
><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>
|
||||||
<li style="line-height: 23px">
|
|
||||||
<span
|
<li style="line-height: 23px; margin-bottom: 8px;">
|
||||||
style="
|
<span style="
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
"><b>Clear resolution criteria. </b>Any ambiguities or edge cases in your description
|
||||||
><b>Clear resolution date</b>. This is
|
will drive traders away from your markets.</span>
|
||||||
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>
|
||||||
<li style="line-height: 23px">
|
|
||||||
<span
|
<li style="line-height: 23px; margin-bottom: 8px;">
|
||||||
style="
|
<span style="
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
"><b>Detailed description. </b>Include images/videos/tweets and any context or
|
||||||
><b>Detailed description. </b>Use the rich
|
background
|
||||||
text editor to create an easy to read
|
|
||||||
description. Include any context or background
|
|
||||||
information that could be useful to people who
|
information that could be useful to people who
|
||||||
are interested in learning more that are
|
are interested in learning more that are
|
||||||
uneducated on the subject.</span
|
uneducated on the subject.</span>
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
<li style="line-height: 23px">
|
<li style="line-height: 23px; margin-bottom: 8px;">
|
||||||
<span
|
<span style="
|
||||||
style="
|
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
"><b>Add it to a group. </b>Groups are the
|
||||||
><b>Add it to a group. </b>Groups are the
|
|
||||||
primary way users filter for relevant markets.
|
primary way users filter for relevant markets.
|
||||||
Also, consider making your own groups and
|
Also, consider making your own groups and
|
||||||
inviting friends/interested communities to
|
inviting friends/interested communities to
|
||||||
them from other sites!</span
|
them from other sites!</span>
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
<li style="line-height: 23px">
|
<li style="line-height: 23px; margin-bottom: 8px;">
|
||||||
<span
|
<span style="
|
||||||
style="
|
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
"><b>Share it on social media</b>. You'll earn the <a class="link-build-content"
|
||||||
><b>Bonus: </b>Add a comment on your
|
style="color: inherit; text-decoration: none" target="_blank"
|
||||||
prediction and explain (with links and
|
href="https://manifold.markets/referrals"><span style="
|
||||||
sources) supporting it.</span
|
color: #55575d;
|
||||||
>
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
"><u>M$500
|
||||||
|
referral bonus</u></span></a> if you get new users to sign up!</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||||
class="text-build-content"
|
data-testid="3Q8BP69fq">
|
||||||
style="line-height: 23px; margin: 10px 0"
|
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
>
|
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||||
class="text-build-content"
|
data-testid="3Q8BP69fq">
|
||||||
style="line-height: 23px; margin: 10px 0"
|
<span style="
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
color: #292fd7;
|
color: #292fd7;
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
"
|
"><b>Examples of markets you should
|
||||||
><b
|
emulate! </b></span>
|
||||||
>Examples of markets you should
|
|
||||||
emulate! </b
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li style="line-height: 23px">
|
<li style="line-height: 23px">
|
||||||
<a
|
<a class="link-build-content" style="color: inherit; text-decoration: none"
|
||||||
class="link-build-content"
|
|
||||||
style="color: inherit; text-decoration: none"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://manifold.markets/DavidChee/will-our-upcoming-twitch-bot-be-a-s"
|
href="https://manifold.markets/DavidChee/will-our-upcoming-twitch-bot-be-a-s"><span
|
||||||
><span
|
|
||||||
style="
|
style="
|
||||||
color: #55575d;
|
color: #55575d;
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
"><u>This complex market</u></span></a><span style="
|
||||||
><u>This complex market</u></span
|
|
||||||
></a
|
|
||||||
><span
|
|
||||||
style="
|
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
">
|
||||||
>
|
about the project I am working on.</span>
|
||||||
about the project I am working on.</span
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
<li style="line-height: 23px">
|
<li style="line-height: 23px">
|
||||||
<a
|
<a class="link-build-content" style="color: inherit; text-decoration: none"
|
||||||
class="link-build-content"
|
|
||||||
style="color: inherit; text-decoration: none"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://manifold.markets/SneakySly/will-manifold-reach-1000-weekly-act"
|
href="https://manifold.markets/SneakySly/will-manifold-reach-1000-weekly-act"><span
|
||||||
><span
|
|
||||||
style="
|
style="
|
||||||
color: #55575d;
|
color: #55575d;
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
"><u>This simple market</u></span></a><span style="
|
||||||
><u>This simple market</u></span
|
|
||||||
></a
|
|
||||||
><span
|
|
||||||
style="
|
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
">
|
||||||
>
|
|
||||||
about Manifold's weekly active
|
about Manifold's weekly active
|
||||||
users.</span
|
users.</span>
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||||
class="text-build-content"
|
data-testid="3Q8BP69fq">
|
||||||
style="line-height: 23px; margin: 10px 0"
|
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
>
|
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||||
class="text-build-content"
|
data-testid="3Q8BP69fq">
|
||||||
style="line-height: 23px; margin: 10px 0"
|
<span style="
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
color: #000000;
|
color: #000000;
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
">Why not </span>
|
||||||
>Why not </span>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<a
|
<a class="link-build-content" style="color: inherit; text-decoration: none" target="_blank"
|
||||||
class="link-build-content"
|
href="https://manifold.markets/create"><span style="
|
||||||
style="color: inherit; text-decoration: none"
|
|
||||||
target="_blank"
|
|
||||||
href="https://manifold.markets/create"
|
|
||||||
><span
|
|
||||||
style="
|
|
||||||
color: #55575d;
|
color: #55575d;
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
"><u>create another market</u></span></a><span style="
|
||||||
><u>create a market</u></span
|
|
||||||
></a
|
|
||||||
><span
|
|
||||||
style="
|
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
">
|
||||||
>
|
|
||||||
while it is still fresh on your mind?
|
while it is still fresh on your mind?
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
|
||||||
class="text-build-content"
|
data-testid="3Q8BP69fq">
|
||||||
style="line-height: 23px; margin: 10px 0"
|
<span style="
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
color: #000000;
|
color: #000000;
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
">Thanks for reading!</span>
|
||||||
>Thanks for reading!</span
|
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p class="text-build-content" style="
|
||||||
class="text-build-content"
|
|
||||||
style="
|
|
||||||
line-height: 23px;
|
line-height: 23px;
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
"
|
" data-testid="3Q8BP69fq">
|
||||||
data-testid="3Q8BP69fq"
|
<span style="
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
color: #000000;
|
color: #000000;
|
||||||
font-family: Readex Pro, Arial, Helvetica,
|
font-family: Readex Pro, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
"
|
">David from Manifold</span>
|
||||||
>David from Manifold</span
|
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -606,118 +447,73 @@
|
||||||
</div>
|
</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]-->
|
<!--[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">
|
<div style="margin: 0px auto; max-width: 600px">
|
||||||
<table
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
|
||||||
align="center"
|
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td style="
|
||||||
style="
|
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
padding: 0 0 20px 0;
|
padding: 0 0 20px 0;
|
||||||
text-align: center;
|
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]-->
|
<!--[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">
|
<div style="margin: 0px auto; max-width: 600px">
|
||||||
<table
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
align="center"
|
style="width: 100%">
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td style="
|
||||||
style="
|
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
padding: 20px 0px 20px 0px;
|
padding: 20px 0px 20px 0px;
|
||||||
text-align: center;
|
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]-->
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
<div
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||||
class="mj-column-per-100 mj-outlook-group-fix"
|
|
||||||
style="
|
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
"
|
">
|
||||||
>
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||||
<table
|
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="vertical-align: top; padding: 0">
|
<td style="vertical-align: top; padding: 0">
|
||||||
<table
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="center" style="
|
||||||
align="center"
|
|
||||||
style="
|
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
padding: 10px 25px;
|
padding: 10px 25px;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
"
|
">
|
||||||
>
|
<div style="
|
||||||
<div
|
|
||||||
style="
|
|
||||||
font-family: Ubuntu, Helvetica, Arial,
|
font-family: Ubuntu, Helvetica, Arial,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
"
|
">
|
||||||
>
|
|
||||||
<p style="margin: 10px 0">
|
<p style="margin: 10px 0">
|
||||||
This e-mail has been sent to {{name}},
|
This e-mail has been sent to {{name}},
|
||||||
<a
|
<a href="{{unsubscribeLink}}" style="
|
||||||
href="{{unsubscribeLink}}"
|
|
||||||
style="
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
"
|
" target="_blank">click here to unsubscribe</a>.
|
||||||
target="_blank"
|
|
||||||
>click here to unsubscribe</a
|
|
||||||
>.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="center" style="
|
||||||
align="center"
|
|
||||||
style="
|
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
padding: 10px 25px;
|
padding: 10px 25px;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
"
|
"></td>
|
||||||
></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@ -735,4 +531,5 @@
|
||||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -1,32 +1,33 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
xmlns="http://www.w3.org/1999/xhtml"
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
xmlns:v="urn:schemas-microsoft-com:vml"
|
|
||||||
xmlns:o="urn:schemas-microsoft-com:office:office"
|
|
||||||
>
|
|
||||||
<head>
|
<head>
|
||||||
<title>Welcome to Manifold Markets</title>
|
<title>Welcome to Manifold Markets!</title>
|
||||||
<!--[if !mso]><!-->
|
<!--[if !mso]><!-->
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<!--<![endif]-->
|
<!--<![endif]-->
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
#outlook a {
|
#outlook a {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
-ms-text-size-adjust: 100%;
|
-ms-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
table,
|
table,
|
||||||
td {
|
td {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
mso-table-lspace: 0pt;
|
mso-table-lspace: 0pt;
|
||||||
mso-table-rspace: 0pt;
|
mso-table-rspace: 0pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
border: 0;
|
border: 0;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
@ -35,6 +36,7 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
-ms-interpolation-mode: bicubic;
|
-ms-interpolation-mode: bicubic;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 13px 0;
|
margin: 13px 0;
|
||||||
|
@ -52,9 +54,7 @@
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<!--[if lte mso 11]>
|
<!--[if lte mso 11]>
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
.mj-outlook-group-fix {
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
|
@ -63,10 +63,6 @@
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.mj-column-per-50 {
|
|
||||||
width: 50% !important;
|
|
||||||
max-width: 50%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<style media="screen and (min-width:480px)">
|
<style media="screen and (min-width:480px)">
|
||||||
|
@ -74,615 +70,189 @@
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.moz-text-html .mj-column-per-50 {
|
|
||||||
width: 50% !important;
|
|
||||||
max-width: 50%;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
[owa] .mj-column-per-100 {
|
[owa] .mj-column-per-100 {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
[owa] .mj-column-per-50 {
|
|
||||||
width: 50% !important;
|
|
||||||
max-width: 50%;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
@media only screen and (max-width:480px) {
|
@media only screen and (max-width:480px) {
|
||||||
table.mj-full-width-mobile {
|
table.mj-full-width-mobile {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
td.mj-full-width-mobile {
|
td.mj-full-width-mobile {
|
||||||
width: auto !important;
|
width: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
|
||||||
<div style="background-color: #f4f4f4">
|
<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]-->
|
<!--[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
|
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
|
||||||
style="
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
background: #ffffff;
|
style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||||
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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
style="
|
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;">
|
||||||
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]-->
|
<!--[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/urx/sjtz.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>
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
class="mj-column-per-100 mj-outlook-group-fix"
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
style="
|
<p class="text-build-content"
|
||||||
font-size: 0px;
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
text-align: left;
|
data-testid="4XoHRGw1Y"><span
|
||||||
direction: ltr;
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
display: inline-block;
|
Hi {{name}},</span></p>
|
||||||
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/urx/sjtz.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>
|
</div>
|
||||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
<tr>
|
||||||
</table>
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<p class="text-build-content"
|
||||||
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
|
data-testid="4XoHRGw1Y"><span
|
||||||
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
|
Welcome! Manifold Markets is a play-money prediction market platform where you can bet on
|
||||||
|
anything, from elections to Elon Musk to scientific papers to the NBA. </span></p>
|
||||||
</div>
|
</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]-->
|
</td>
|
||||||
<div
|
</tr>
|
||||||
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>
|
<tr>
|
||||||
<td
|
<td align="left"
|
||||||
style="
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
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:300px;" ><![endif]-->
|
|
||||||
<div
|
<div
|
||||||
class="mj-column-per-50 mj-outlook-group-fix"
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
style="
|
<p class="text-build-content"
|
||||||
font-size: 0px;
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
text-align: left;
|
data-testid="4XoHRGw1Y"><span
|
||||||
direction: ltr;
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
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="
|
|
||||||
background: #ffffff;
|
|
||||||
font-size: 0px;
|
|
||||||
padding: 0px 15px 0px 50px;
|
|
||||||
padding-top: 0px;
|
|
||||||
padding-right: 15px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
padding-left: 50px;
|
|
||||||
word-break: break-word;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 18px;
|
|
||||||
letter-spacing: normal;
|
|
||||||
line-height: 1;
|
|
||||||
text-align: left;
|
|
||||||
color: #000000;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
class="text-build-content"
|
|
||||||
data-testid="e885nY30eE"
|
|
||||||
style="margin: 10px 0; margin-top: 10px"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
><b
|
|
||||||
>Hi {{name}}, thanks for joining Manifold
|
|
||||||
Markets!</b
|
|
||||||
></span
|
|
||||||
><br /><br /><span
|
|
||||||
style="
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
>We can't wait to see what questions you
|
|
||||||
will ask!</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-build-content"
|
|
||||||
style="line-height: 23px; margin: 10px 0"
|
|
||||||
data-testid="e885nY30eE"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
>As a gift M$1000 has been credited to your
|
|
||||||
account - the equivalent of 10 USD.
|
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p
|
|
||||||
class="text-build-content"
|
|
||||||
style="
|
|
||||||
line-height: 23px;
|
|
||||||
margin: 10px 0;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
"
|
|
||||||
data-testid="e885nY30eE"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
>Click the buttons to see what you can do with
|
|
||||||
it!</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
|
|
||||||
<div
|
|
||||||
class="mj-column-per-50 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>
|
<tr>
|
||||||
<td
|
<td>
|
||||||
align="center"
|
<p></p>
|
||||||
style="
|
</td>
|
||||||
font-size: 0px;
|
</tr>
|
||||||
padding: 0px 15px 0px 0px;
|
|
||||||
padding-top: 0px;
|
|
||||||
padding-right: 15px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
padding-left: 0px;
|
|
||||||
word-break: break-word;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<table
|
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
style="
|
|
||||||
border-collapse: collapse;
|
|
||||||
border-spacing: 0px;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width: 285px">
|
<td align="center">
|
||||||
<a
|
<table cellspacing="0" cellpadding="0">
|
||||||
href="https://manifold.markets/home"
|
<tr>
|
||||||
target="_blank"
|
<td>
|
||||||
>
|
<table cellspacing="0" cellpadding="0">
|
||||||
<img
|
<tr>
|
||||||
alt=""
|
<td style="border-radius: 2px;" bgcolor="#4337c9">
|
||||||
height="auto"
|
<a href="https://manifold.markets" target="_blank"
|
||||||
src="https://03jlj.mjt.lu/img/03jlj/b/urx/1rsm.png"
|
style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:bold;display: inline-block;">
|
||||||
style="
|
Explore markets
|
||||||
border: none;
|
|
||||||
display: block;
|
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
height: auto;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 13px;
|
|
||||||
"
|
|
||||||
width="285"
|
|
||||||
/>
|
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="left"
|
||||||
align="center"
|
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
||||||
style="
|
<div
|
||||||
font-size: 0px;
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
padding: 0px 15px 0px 0px;
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;"
|
||||||
padding-top: 0px;
|
data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did
|
||||||
padding-right: 15px;
|
you know, besides betting and making predictions, you can also <a
|
||||||
padding-bottom: 0px;
|
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||||
padding-left: 0px;
|
target="_blank" href="https://manifold.markets/create"><span
|
||||||
word-break: break-word;
|
style="color:#55575d;font-family:Arial;font-size:18px;font-weight: bold;"><u>create
|
||||||
"
|
your
|
||||||
>
|
own
|
||||||
<table
|
market</u></span></a> on
|
||||||
border="0"
|
any question you care about?</span></p>
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
<p>More resources:</p>
|
||||||
role="presentation"
|
|
||||||
style="
|
<ul>
|
||||||
border-collapse: collapse;
|
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||||
border-spacing: 0px;
|
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||||
"
|
target="_blank" href="https://manifold.markets/about"><span
|
||||||
>
|
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Learn more</u></span></a>
|
||||||
<tbody>
|
about Manifold and how our markets work</span></li>
|
||||||
<tr>
|
|
||||||
<td style="width: 285px">
|
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||||
<a
|
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||||
href="https://manifold.markets/create"
|
target="_blank" href="https://manifold.markets/referrals"><span
|
||||||
target="_blank"
|
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Refer
|
||||||
>
|
your friends</u></span></a> and earn M$500 for each signup!</span></li>
|
||||||
<img
|
|
||||||
alt=""
|
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||||
height="auto"
|
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||||
src="https://03jlj.mjt.lu/img/03jlj/b/urx/1rs2.png"
|
target="_blank" href="https://discord.com/invite/eHQBNBqXuh"><span
|
||||||
style="
|
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Join our Discord
|
||||||
border: none;
|
chat</u></span></a></span></li>
|
||||||
display: block;
|
</ul>
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p>
|
||||||
height: auto;
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||||
width: 100%;
|
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
|
||||||
font-size: 13px;
|
</p>
|
||||||
"
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||||
width="285"
|
style="color:#000000;font-family:Arial;font-size:18px;">David
|
||||||
/>
|
from Manifold</span></p>
|
||||||
</a>
|
<p class="text-build-content" data-testid="3Q8BP69fq"
|
||||||
</td>
|
style="margin: 10px 0; margin-bottom: 10px;"> </p>
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
align="center"
|
|
||||||
style="
|
|
||||||
font-size: 0px;
|
|
||||||
padding: 0px 15px 0px 0px;
|
|
||||||
padding-top: 0px;
|
|
||||||
padding-right: 15px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
padding-left: 0px;
|
|
||||||
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: 285px">
|
|
||||||
<a
|
|
||||||
href="https://manifold.markets/charity"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
height="auto"
|
|
||||||
src="https://03jlj.mjt.lu/img/03jlj/b/urx/1rsp.png"
|
|
||||||
style="
|
|
||||||
border: none;
|
|
||||||
display: block;
|
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
height: auto;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 13px;
|
|
||||||
"
|
|
||||||
width="285"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td
|
<td align="left"
|
||||||
style="
|
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
||||||
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
|
<div
|
||||||
class="mj-column-per-100 mj-outlook-group-fix"
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
style="
|
<p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;"
|
||||||
font-size: 0px;
|
data-testid="3Q8BP69fq"></a></li>
|
||||||
text-align: left;
|
</ul>
|
||||||
direction: ltr;
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p>
|
||||||
display: inline-block;
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||||
vertical-align: top;
|
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span></p>
|
||||||
width: 100%;
|
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||||
"
|
style="color:#000000;font-family:Arial;font-size:18px;">David from Manifold</span></p>
|
||||||
>
|
<p class="text-build-content" data-testid="3Q8BP69fq"
|
||||||
<table
|
style="margin: 10px 0; margin-bottom: 10px;"> </p>
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
style="vertical-align: top"
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
align="left"
|
|
||||||
style="
|
|
||||||
font-size: 0px;
|
|
||||||
padding: 10px 25px;
|
|
||||||
padding-top: 0px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
word-break: break-word;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 18px;
|
|
||||||
letter-spacing: normal;
|
|
||||||
line-height: 1;
|
|
||||||
text-align: left;
|
|
||||||
color: #000000;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
class="text-build-content"
|
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
style="margin: 10px 0; margin-top: 10px"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
color: #55575d;
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
>I</span
|
|
||||||
><span
|
|
||||||
style="
|
|
||||||
color: #000000;
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
>f you have any questions or feedback we'd
|
|
||||||
love to hear from you in our </span
|
|
||||||
><a
|
|
||||||
class="link-build-content"
|
|
||||||
style="color: inherit; text-decoration: none"
|
|
||||||
target="_blank"
|
|
||||||
href="https://discord.gg/VARzUpyCSa"
|
|
||||||
><span
|
|
||||||
style="
|
|
||||||
color: #1435b8;
|
|
||||||
font-family: Arial;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
><u>Discord server!</u></span
|
|
||||||
></a
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-build-content"
|
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
style="margin: 10px 0"
|
|
||||||
></p>
|
|
||||||
<p
|
|
||||||
class="text-build-content"
|
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
style="margin: 10px 0"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
color: #000000;
|
|
||||||
font-family: Arial;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
>Looking forward to seeing you,</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-build-content"
|
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
style="margin: 10px 0"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
color: #000000;
|
|
||||||
font-family: Arial;
|
|
||||||
font-size: 18px;
|
|
||||||
"
|
|
||||||
>David from Manifold</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
<br />
|
|
||||||
<p
|
|
||||||
class="text-build-content"
|
|
||||||
data-testid="3Q8BP69fq"
|
|
||||||
style="margin: 10px 0; margin-bottom: 10px"
|
|
||||||
></p>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -696,112 +266,54 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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]-->
|
<!--[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">
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
<table
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
align="center"
|
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td style="direction:ltr;font-size:0px;padding:0 0 20px 0;text-align:center;">
|
||||||
style="
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
direction: ltr;
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
font-size: 0px;
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
padding: 0 0 20px 0;
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
text-align: center;
|
width="100%">
|
||||||
"
|
<tbody>
|
||||||
></td>
|
<tr>
|
||||||
</tr>
|
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||||
</tbody>
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
</table>
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
</div>
|
</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]-->
|
<!--[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">
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
<table
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
align="center"
|
style="width:100%;">
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td style="direction:ltr;font-size:0px;padding:20px 0px 20px 0px;text-align:center;">
|
||||||
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]-->
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
<div
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
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%;">
|
||||||
style="
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||||
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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="vertical-align: top; padding: 0">
|
<td style="vertical-align:top;padding:0;">
|
||||||
<table
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||||
border="0"
|
|
||||||
cellpadding="0"
|
|
||||||
cellspacing="0"
|
|
||||||
role="presentation"
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td align="center"
|
||||||
align="center"
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
style="
|
|
||||||
font-size: 0px;
|
|
||||||
padding: 10px 25px;
|
|
||||||
padding-top: 0px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
word-break: break-word;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
||||||
font-family: Arial, sans-serif;
|
<p style="margin: 10px 0;">This e-mail has been sent to {{name}}, <a
|
||||||
font-size: 11px;
|
href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;"
|
||||||
letter-spacing: normal;
|
target="_blank">click here to unsubscribe</a>.</p>
|
||||||
line-height: 22px;
|
</div>
|
||||||
text-align: center;
|
</td>
|
||||||
color: #000000;
|
</tr>
|
||||||
"
|
<tr>
|
||||||
>
|
<td align="center"
|
||||||
<p style="margin: 10px 0">
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
This e-mail has been sent to {{name}},
|
<div
|
||||||
<a
|
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
|
||||||
href="{{unsubscribeLink}}"
|
|
||||||
style="
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
"
|
|
||||||
target="_blank"
|
|
||||||
>click here to unsubscribe</a
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -821,4 +333,5 @@
|
||||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -1,3 +1,5 @@
|
||||||
|
import * as dayjs from 'dayjs'
|
||||||
|
|
||||||
import { DOMAIN } from '../../common/envs/constants'
|
import { DOMAIN } from '../../common/envs/constants'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
|
@ -14,9 +16,10 @@ import {
|
||||||
import { getValueFromBucket } from '../../common/calculate-dpm'
|
import { getValueFromBucket } from '../../common/calculate-dpm'
|
||||||
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||||
|
|
||||||
import { sendTemplateEmail } from './send-email'
|
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||||
import { getPrivateUser, getUser } from './utils'
|
import { getPrivateUser, getUser } from './utils'
|
||||||
import { getFunctionUrl } from '../../common/api'
|
import { getFunctionUrl } from '../../common/api'
|
||||||
|
import { richTextToString } from '../../common/util/parse'
|
||||||
|
|
||||||
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
|
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
|
||||||
|
|
||||||
|
@ -73,9 +76,8 @@ export const sendMarketResolutionEmail = async (
|
||||||
|
|
||||||
// Modify template here:
|
// Modify template here:
|
||||||
// https://app.mailgun.com/app/sending/domains/mg.manifold.markets/templates/edit/market-resolved/initial
|
// https://app.mailgun.com/app/sending/domains/mg.manifold.markets/templates/edit/market-resolved/initial
|
||||||
// Mailgun username: james@mantic.markets
|
|
||||||
|
|
||||||
await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
subject,
|
subject,
|
||||||
'market-resolved',
|
'market-resolved',
|
||||||
|
@ -151,7 +153,7 @@ export const sendWelcomeEmail = async (
|
||||||
const emailType = 'generic'
|
const emailType = 'generic'
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||||
|
|
||||||
await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Welcome to Manifold Markets!',
|
'Welcome to Manifold Markets!',
|
||||||
'welcome',
|
'welcome',
|
||||||
|
@ -165,6 +167,43 @@ export const sendWelcomeEmail = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const sendPersonalFollowupEmail = async (
|
||||||
|
user: User,
|
||||||
|
privateUser: PrivateUser
|
||||||
|
) => {
|
||||||
|
if (!privateUser || !privateUser.email) return
|
||||||
|
|
||||||
|
const { name } = user
|
||||||
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
|
const emailBody = `Hi ${firstName},
|
||||||
|
|
||||||
|
Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your exprience on the platform so far?
|
||||||
|
|
||||||
|
If you haven't already, I encourage you to try creating your own prediction market (https://manifold.markets/create) and joining our Discord chat (https://discord.com/invite/eHQBNBqXuh).
|
||||||
|
|
||||||
|
Feel free to reply to this email with any questions or concerns you have.
|
||||||
|
|
||||||
|
Cheers,
|
||||||
|
|
||||||
|
James
|
||||||
|
Cofounder of Manifold Markets
|
||||||
|
https://manifold.markets
|
||||||
|
`
|
||||||
|
|
||||||
|
const sendTime = dayjs().add(4, 'hours').toString()
|
||||||
|
|
||||||
|
await sendTextEmail(
|
||||||
|
privateUser.email,
|
||||||
|
'How are you finding Manifold?',
|
||||||
|
emailBody,
|
||||||
|
{
|
||||||
|
from: 'James from Manifold <james@manifold.markets>',
|
||||||
|
'o:deliverytime': sendTime,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const sendOneWeekBonusEmail = async (
|
export const sendOneWeekBonusEmail = async (
|
||||||
user: User,
|
user: User,
|
||||||
privateUser: PrivateUser
|
privateUser: PrivateUser
|
||||||
|
@ -182,7 +221,7 @@ export const sendOneWeekBonusEmail = async (
|
||||||
const emailType = 'generic'
|
const emailType = 'generic'
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||||
|
|
||||||
await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Manifold Markets one week anniversary gift',
|
'Manifold Markets one week anniversary gift',
|
||||||
'one-week',
|
'one-week',
|
||||||
|
@ -197,6 +236,37 @@ export const sendOneWeekBonusEmail = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const sendCreatorGuideEmail = async (
|
||||||
|
user: User,
|
||||||
|
privateUser: PrivateUser
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
!privateUser ||
|
||||||
|
!privateUser.email ||
|
||||||
|
privateUser.unsubscribedFromGenericEmails
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
const { name, id: userId } = user
|
||||||
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
|
const emailType = 'generic'
|
||||||
|
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||||
|
|
||||||
|
return await sendTemplateEmail(
|
||||||
|
privateUser.email,
|
||||||
|
'Market creation guide',
|
||||||
|
'creating-market',
|
||||||
|
{
|
||||||
|
name: firstName,
|
||||||
|
unsubscribeLink,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'David from Manifold <david@manifold.markets>',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const sendThankYouEmail = async (
|
export const sendThankYouEmail = async (
|
||||||
user: User,
|
user: User,
|
||||||
privateUser: PrivateUser
|
privateUser: PrivateUser
|
||||||
|
@ -214,7 +284,7 @@ export const sendThankYouEmail = async (
|
||||||
const emailType = 'generic'
|
const emailType = 'generic'
|
||||||
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||||
|
|
||||||
await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Thanks for your Manifold purchase',
|
'Thanks for your Manifold purchase',
|
||||||
'thank-you',
|
'thank-you',
|
||||||
|
@ -249,7 +319,7 @@ export const sendMarketCloseEmail = async (
|
||||||
const emailType = 'market-resolve'
|
const emailType = 'market-resolve'
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||||
|
|
||||||
await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Your market has closed',
|
'Your market has closed',
|
||||||
'market-close',
|
'market-close',
|
||||||
|
@ -291,7 +361,8 @@ export const sendNewCommentEmail = async (
|
||||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||||
|
|
||||||
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
|
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
|
||||||
const { text } = comment
|
const { content } = comment
|
||||||
|
const text = richTextToString(content)
|
||||||
|
|
||||||
let betDescription = ''
|
let betDescription = ''
|
||||||
if (bet) {
|
if (bet) {
|
||||||
|
@ -307,7 +378,7 @@ export const sendNewCommentEmail = async (
|
||||||
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
||||||
const answerNumber = `#${answerId}`
|
const answerNumber = `#${answerId}`
|
||||||
|
|
||||||
await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
subject,
|
subject,
|
||||||
'market-answer-comment',
|
'market-answer-comment',
|
||||||
|
@ -330,7 +401,7 @@ export const sendNewCommentEmail = async (
|
||||||
bet.outcome
|
bet.outcome
|
||||||
)}`
|
)}`
|
||||||
}
|
}
|
||||||
await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
subject,
|
subject,
|
||||||
'market-comment',
|
'market-comment',
|
||||||
|
@ -375,7 +446,7 @@ export const sendNewAnswerEmail = async (
|
||||||
const subject = `New answer on ${question}`
|
const subject = `New answer on ${question}`
|
||||||
const from = `${name} <info@manifold.markets>`
|
const from = `${name} <info@manifold.markets>`
|
||||||
|
|
||||||
await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
subject,
|
subject,
|
||||||
'market-answer',
|
'market-answer',
|
||||||
|
|
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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
|
@ -65,6 +65,7 @@ import { unsubscribe } from './unsubscribe'
|
||||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||||
import { getcurrentuser } from './get-current-user'
|
import { getcurrentuser } from './get-current-user'
|
||||||
import { acceptchallenge } from './accept-challenge'
|
import { acceptchallenge } from './accept-challenge'
|
||||||
|
import { getcustomtoken } from './get-custom-token'
|
||||||
|
|
||||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||||
return onRequest(opts, handler as any)
|
return onRequest(opts, handler as any)
|
||||||
|
@ -89,6 +90,7 @@ const stripeWebhookFunction = toCloudFunction(stripewebhook)
|
||||||
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
||||||
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||||
const acceptChallenge = toCloudFunction(acceptchallenge)
|
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||||
|
const getCustomTokenFunction = toCloudFunction(getcustomtoken)
|
||||||
|
|
||||||
export {
|
export {
|
||||||
healthFunction as health,
|
healthFunction as health,
|
||||||
|
@ -111,4 +113,5 @@ export {
|
||||||
createCheckoutSessionFunction as createcheckoutsession,
|
createCheckoutSessionFunction as createcheckoutsession,
|
||||||
getCurrentUserFunction as getcurrentuser,
|
getCurrentUserFunction as getcurrentuser,
|
||||||
acceptChallenge as acceptchallenge,
|
acceptChallenge as acceptchallenge,
|
||||||
|
getCustomTokenFunction as getcustomtoken,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { uniq } from 'lodash'
|
import { compact, uniq } from 'lodash'
|
||||||
|
|
||||||
import { getContract, getUser, getValues } from './utils'
|
import { getContract, getUser, getValues } from './utils'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { sendNewCommentEmail } from './emails'
|
import { sendNewCommentEmail } from './emails'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
import { createNotification } from './create-notification'
|
import { createNotification } from './create-notification'
|
||||||
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
@ -24,6 +24,11 @@ export const onCreateCommentOnContract = functions
|
||||||
if (!contract)
|
if (!contract)
|
||||||
throw new Error('Could not find contract corresponding with comment')
|
throw new Error('Could not find contract corresponding with comment')
|
||||||
|
|
||||||
|
await change.ref.update({
|
||||||
|
contractSlug: contract.slug,
|
||||||
|
contractQuestion: contract.question,
|
||||||
|
})
|
||||||
|
|
||||||
const comment = change.data() as Comment
|
const comment = change.data() as Comment
|
||||||
const lastCommentTime = comment.createdTime
|
const lastCommentTime = comment.createdTime
|
||||||
|
|
||||||
|
@ -71,7 +76,10 @@ export const onCreateCommentOnContract = functions
|
||||||
const repliedUserId = comment.replyToCommentId
|
const repliedUserId = comment.replyToCommentId
|
||||||
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
||||||
: answer?.userId
|
: answer?.userId
|
||||||
const recipients = repliedUserId ? [repliedUserId] : []
|
|
||||||
|
const recipients = uniq(
|
||||||
|
compact([...parseMentions(comment.content), repliedUserId])
|
||||||
|
)
|
||||||
|
|
||||||
await createNotification(
|
await createNotification(
|
||||||
comment.id,
|
comment.id,
|
||||||
|
@ -79,7 +87,7 @@ export const onCreateCommentOnContract = functions
|
||||||
'created',
|
'created',
|
||||||
commentCreator,
|
commentCreator,
|
||||||
eventId,
|
eventId,
|
||||||
comment.text,
|
richTextToString(comment.content),
|
||||||
{ contract, relatedSourceType, recipients }
|
{ contract, relatedSourceType, recipients }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import { getUser } from './utils'
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
import { getPrivateUser, getUser } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createNotification } from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import { sendCreatorGuideEmail } from './emails'
|
||||||
|
|
||||||
export const onCreateContract = functions.firestore
|
export const onCreateContract = functions
|
||||||
.document('contracts/{contractId}')
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
|
.firestore.document('contracts/{contractId}')
|
||||||
.onCreate(async (snapshot, context) => {
|
.onCreate(async (snapshot, context) => {
|
||||||
const contract = snapshot.data() as Contract
|
const contract = snapshot.data() as Contract
|
||||||
const { eventId } = context
|
const { eventId } = context
|
||||||
|
@ -26,4 +31,23 @@ export const onCreateContract = functions.firestore
|
||||||
richTextToString(desc),
|
richTextToString(desc),
|
||||||
{ contract, recipients: mentioned }
|
{ contract, recipients: mentioned }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await sendGuideEmail(contractCreator)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
const sendGuideEmail = async (contractCreator: User) => {
|
||||||
|
const query = await firestore
|
||||||
|
.collection(`contracts`)
|
||||||
|
.where('creatorId', '==', contractCreator.id)
|
||||||
|
.limit(2)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (query.size >= 2) return
|
||||||
|
|
||||||
|
const privateUser = await getPrivateUser(contractCreator.id)
|
||||||
|
if (!privateUser) return
|
||||||
|
|
||||||
|
await sendCreatorGuideEmail(contractCreator, privateUser)
|
||||||
|
}
|
||||||
|
|
|
@ -82,7 +82,22 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
(outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
|
(outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
|
||||||
mechanism == 'cpmm-1'
|
mechanism == 'cpmm-1'
|
||||||
) {
|
) {
|
||||||
const { outcome, limitProb } = validate(binarySchema, req.body)
|
// eslint-disable-next-line prefer-const
|
||||||
|
let { outcome, limitProb } = validate(binarySchema, req.body)
|
||||||
|
|
||||||
|
if (limitProb !== undefined && outcomeType === 'BINARY') {
|
||||||
|
const isRounded = floatingEqual(
|
||||||
|
Math.round(limitProb * 100),
|
||||||
|
limitProb * 100
|
||||||
|
)
|
||||||
|
if (!isRounded)
|
||||||
|
throw new APIError(
|
||||||
|
400,
|
||||||
|
'limitProb must be in increments of 0.01 (i.e. whole percentage points)'
|
||||||
|
)
|
||||||
|
|
||||||
|
limitProb = Math.round(limitProb * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
const unfilledBetsSnap = await trans.get(
|
const unfilledBetsSnap = await trans.get(
|
||||||
getUnfilledBetsQuery(contractDoc)
|
getUnfilledBetsQuery(contractDoc)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
|
import { difference, mapValues, groupBy, sumBy } from 'lodash'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
|
@ -18,10 +18,12 @@ import {
|
||||||
groupPayoutsByUser,
|
groupPayoutsByUser,
|
||||||
Payout,
|
Payout,
|
||||||
} from '../../common/payouts'
|
} from '../../common/payouts'
|
||||||
import { isAdmin } from '../../common/envs/constants'
|
import { isManifoldId } from '../../common/envs/constants'
|
||||||
import { removeUndefinedProps } from '../../common/util/object'
|
import { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
|
import { getContractBetMetrics } from '../../common/calculate'
|
||||||
|
import { floatingEqual } from '../../common/util/math'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
|
@ -82,7 +84,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
req.body
|
req.body
|
||||||
)
|
)
|
||||||
|
|
||||||
if (creatorId !== auth.uid && !isAdmin(auth.uid))
|
if (creatorId !== auth.uid && !isManifoldId(auth.uid))
|
||||||
throw new APIError(403, 'User is not creator of contract')
|
throw new APIError(403, 'User is not creator of contract')
|
||||||
|
|
||||||
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
||||||
|
@ -162,7 +164,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
||||||
|
|
||||||
await sendResolutionEmails(
|
await sendResolutionEmails(
|
||||||
openBets,
|
bets,
|
||||||
userPayoutsWithoutLoans,
|
userPayoutsWithoutLoans,
|
||||||
creator,
|
creator,
|
||||||
creatorPayout,
|
creatorPayout,
|
||||||
|
@ -188,7 +190,7 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendResolutionEmails = async (
|
const sendResolutionEmails = async (
|
||||||
openBets: Bet[],
|
bets: Bet[],
|
||||||
userPayouts: { [userId: string]: number },
|
userPayouts: { [userId: string]: number },
|
||||||
creator: User,
|
creator: User,
|
||||||
creatorPayout: number,
|
creatorPayout: number,
|
||||||
|
@ -197,14 +199,15 @@ const sendResolutionEmails = async (
|
||||||
resolutionProbability?: number,
|
resolutionProbability?: number,
|
||||||
resolutions?: { [outcome: string]: number }
|
resolutions?: { [outcome: string]: number }
|
||||||
) => {
|
) => {
|
||||||
const nonWinners = difference(
|
|
||||||
uniq(openBets.map(({ userId }) => userId)),
|
|
||||||
Object.keys(userPayouts)
|
|
||||||
)
|
|
||||||
const investedByUser = mapValues(
|
const investedByUser = mapValues(
|
||||||
groupBy(openBets, (bet) => bet.userId),
|
groupBy(bets, (bet) => bet.userId),
|
||||||
(bets) => sumBy(bets, (bet) => bet.amount)
|
(bets) => getContractBetMetrics(contract, bets).invested
|
||||||
)
|
)
|
||||||
|
const investedUsers = Object.keys(investedByUser).filter(
|
||||||
|
(userId) => !floatingEqual(investedByUser[userId], 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const nonWinners = difference(investedUsers, Object.keys(userPayouts))
|
||||||
const emailPayouts = [
|
const emailPayouts = [
|
||||||
...Object.entries(userPayouts),
|
...Object.entries(userPayouts),
|
||||||
...nonWinners.map((userId) => [userId, 0] as const),
|
...nonWinners.map((userId) => [userId, 0] as const),
|
||||||
|
|
70
functions/src/scripts/denormalize-comment-contract-data.ts
Normal file
70
functions/src/scripts/denormalize-comment-contract-data.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
// Filling in the contract-based fields on comments.
|
||||||
|
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import {
|
||||||
|
DocumentCorrespondence,
|
||||||
|
findDiffs,
|
||||||
|
describeDiff,
|
||||||
|
applyDiff,
|
||||||
|
} from './denormalize'
|
||||||
|
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
||||||
|
|
||||||
|
initAdmin()
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function getContractsById(transaction: Transaction) {
|
||||||
|
const contracts = await transaction.get(firestore.collection('contracts'))
|
||||||
|
const results = Object.fromEntries(contracts.docs.map((doc) => [doc.id, doc]))
|
||||||
|
console.log(`Found ${contracts.size} contracts.`)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCommentsByContractId(transaction: Transaction) {
|
||||||
|
const comments = await transaction.get(
|
||||||
|
firestore.collectionGroup('comments').where('contractId', '!=', null)
|
||||||
|
)
|
||||||
|
const results = new Map<string, DocumentSnapshot[]>()
|
||||||
|
comments.forEach((doc) => {
|
||||||
|
const contractId = doc.get('contractId')
|
||||||
|
const contractComments = results.get(contractId) || []
|
||||||
|
contractComments.push(doc)
|
||||||
|
results.set(contractId, contractComments)
|
||||||
|
})
|
||||||
|
console.log(`Found ${comments.size} comments on ${results.size} contracts.`)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async function denormalize() {
|
||||||
|
let hasMore = true
|
||||||
|
while (hasMore) {
|
||||||
|
hasMore = await admin.firestore().runTransaction(async (transaction) => {
|
||||||
|
const [contractsById, commentsByContractId] = await Promise.all([
|
||||||
|
getContractsById(transaction),
|
||||||
|
getCommentsByContractId(transaction),
|
||||||
|
])
|
||||||
|
const mapping = Object.entries(contractsById).map(
|
||||||
|
([id, doc]): DocumentCorrespondence => {
|
||||||
|
return [doc, commentsByContractId.get(id) || []]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const slugDiffs = findDiffs(mapping, 'slug', 'contractSlug')
|
||||||
|
const qDiffs = findDiffs(mapping, 'question', 'contractQuestion')
|
||||||
|
console.log(`Found ${slugDiffs.length} comments with mismatched slugs.`)
|
||||||
|
console.log(`Found ${qDiffs.length} comments with mismatched questions.`)
|
||||||
|
const diffs = slugDiffs.concat(qDiffs)
|
||||||
|
diffs.slice(0, 500).forEach((d) => {
|
||||||
|
console.log(describeDiff(d))
|
||||||
|
applyDiff(transaction, d)
|
||||||
|
})
|
||||||
|
if (diffs.length > 500) {
|
||||||
|
console.log(`Applying first 500 because of Firestore limit...`)
|
||||||
|
}
|
||||||
|
return diffs.length > 500
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
denormalize().catch((e) => console.error(e))
|
||||||
|
}
|
27
functions/src/scripts/set-avatar-cache-headers.ts
Normal file
27
functions/src/scripts/set-avatar-cache-headers.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import { log } from '../utils'
|
||||||
|
|
||||||
|
const app = initAdmin()
|
||||||
|
const ONE_YEAR_SECS = 60 * 60 * 24 * 365
|
||||||
|
const AVATAR_EXTENSION_RE = /\.(gif|tiff|jpe?g|png|webp)$/i
|
||||||
|
|
||||||
|
const processAvatars = async () => {
|
||||||
|
const storage = app.storage()
|
||||||
|
const bucket = storage.bucket(`${app.options.projectId}.appspot.com`)
|
||||||
|
const [files] = await bucket.getFiles({ prefix: 'user-images' })
|
||||||
|
log(`${files.length} avatar images to process.`)
|
||||||
|
for (const file of files) {
|
||||||
|
if (AVATAR_EXTENSION_RE.test(file.name)) {
|
||||||
|
log(`Updating metadata for ${file.name}.`)
|
||||||
|
await file.setMetadata({
|
||||||
|
cacheControl: `public, max-age=${ONE_YEAR_SECS}`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
log(`Skipping ${file.name} because it probably isn't an avatar.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
processAvatars().catch((e) => console.error(e))
|
||||||
|
}
|
|
@ -1,27 +1,35 @@
|
||||||
import * as mailgun from 'mailgun-js'
|
import * as mailgun from 'mailgun-js'
|
||||||
|
import { tryOrLogError } from './utils'
|
||||||
|
|
||||||
const initMailgun = () => {
|
const initMailgun = () => {
|
||||||
const apiKey = process.env.MAILGUN_KEY as string
|
const apiKey = process.env.MAILGUN_KEY as string
|
||||||
return mailgun({ apiKey, domain: 'mg.manifold.markets' })
|
return mailgun({ apiKey, domain: 'mg.manifold.markets' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendTextEmail = (to: string, subject: string, text: string) => {
|
export const sendTextEmail = async (
|
||||||
|
to: string,
|
||||||
|
subject: string,
|
||||||
|
text: string,
|
||||||
|
options?: Partial<mailgun.messages.SendData>
|
||||||
|
) => {
|
||||||
const data: mailgun.messages.SendData = {
|
const data: mailgun.messages.SendData = {
|
||||||
from: 'Manifold Markets <info@manifold.markets>',
|
...options,
|
||||||
|
from: options?.from ?? 'Manifold Markets <info@manifold.markets>',
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
// Don't rewrite urls in plaintext emails
|
// Don't rewrite urls in plaintext emails
|
||||||
'o:tracking-clicks': 'htmlonly',
|
'o:tracking-clicks': 'htmlonly',
|
||||||
}
|
}
|
||||||
const mg = initMailgun()
|
const mg = initMailgun().messages()
|
||||||
return mg.messages().send(data, (error) => {
|
const result = await tryOrLogError(mg.send(data))
|
||||||
if (error) console.log('Error sending email', error)
|
if (result != null) {
|
||||||
else console.log('Sent text email', to, subject)
|
console.log('Sent text email', to, subject)
|
||||||
})
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendTemplateEmail = (
|
export const sendTemplateEmail = async (
|
||||||
to: string,
|
to: string,
|
||||||
subject: string,
|
subject: string,
|
||||||
templateId: string,
|
templateId: string,
|
||||||
|
@ -35,11 +43,13 @@ export const sendTemplateEmail = (
|
||||||
subject,
|
subject,
|
||||||
template: templateId,
|
template: templateId,
|
||||||
'h:X-Mailgun-Variables': JSON.stringify(templateData),
|
'h:X-Mailgun-Variables': JSON.stringify(templateData),
|
||||||
|
'o:tag': templateId,
|
||||||
|
'o:tracking': true,
|
||||||
}
|
}
|
||||||
const mg = initMailgun()
|
const mg = initMailgun().messages()
|
||||||
|
const result = await tryOrLogError(mg.send(data))
|
||||||
return mg.messages().send(data, (error) => {
|
if (result != null) {
|
||||||
if (error) console.log('Error sending email', error)
|
console.log('Sent template email', templateId, to, subject)
|
||||||
else console.log('Sent template email', templateId, to, subject)
|
}
|
||||||
})
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ import { resolvemarket } from './resolve-market'
|
||||||
import { unsubscribe } from './unsubscribe'
|
import { unsubscribe } from './unsubscribe'
|
||||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||||
import { getcurrentuser } from './get-current-user'
|
import { getcurrentuser } from './get-current-user'
|
||||||
|
import { getcustomtoken } from './get-custom-token'
|
||||||
|
|
||||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||||
const app = express()
|
const app = express()
|
||||||
|
@ -64,6 +65,7 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
||||||
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
||||||
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
||||||
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
||||||
|
addEndpointRoute('/getcustomtoken', getcustomtoken)
|
||||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||||
|
|
||||||
app.listen(PORT)
|
app.listen(PORT)
|
||||||
|
|
|
@ -42,6 +42,15 @@ export const writeAsync = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const tryOrLogError = async <T>(task: Promise<T>) => {
|
||||||
|
try {
|
||||||
|
return await task
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const isProd = () => {
|
export const isProd = () => {
|
||||||
return admin.instanceId().app.options.projectId === 'mantic-markets'
|
return admin.instanceId().app.options.projectId === 'mantic-markets'
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "5.25.0",
|
"@typescript-eslint/eslint-plugin": "5.25.0",
|
||||||
"@typescript-eslint/parser": "5.25.0",
|
"@typescript-eslint/parser": "5.25.0",
|
||||||
|
"@types/node": "16.11.11",
|
||||||
"concurrently": "6.5.1",
|
"concurrently": "6.5.1",
|
||||||
"eslint": "8.15.0",
|
"eslint": "8.15.0",
|
||||||
"eslint-plugin-lodash": "^7.4.0",
|
"eslint-plugin-lodash": "^7.4.0",
|
||||||
|
|
|
@ -5,6 +5,7 @@ module.exports = {
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:react-hooks/recommended',
|
'plugin:react-hooks/recommended',
|
||||||
'plugin:@next/next/recommended',
|
'plugin:@next/next/recommended',
|
||||||
|
'prettier',
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-empty-function': 'off',
|
'@typescript-eslint/no-empty-function': 'off',
|
||||||
|
|
|
@ -26,6 +26,7 @@ import { Bet } from 'common/bet'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { SignUpPrompt } from '../sign-up-prompt'
|
import { SignUpPrompt } from '../sign-up-prompt'
|
||||||
import { isIOS } from 'web/lib/util/device'
|
import { isIOS } from 'web/lib/util/device'
|
||||||
|
import { AlertBox } from '../alert-box'
|
||||||
|
|
||||||
export function AnswerBetPanel(props: {
|
export function AnswerBetPanel(props: {
|
||||||
answer: Answer
|
answer: Answer
|
||||||
|
@ -113,6 +114,8 @@ export function AnswerBetPanel(props: {
|
||||||
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
||||||
const currentReturnPercent = formatPercent(currentReturn)
|
const currentReturnPercent = formatPercent(currentReturn)
|
||||||
|
|
||||||
|
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
|
<Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
|
||||||
<Row className="items-center justify-between self-stretch">
|
<Row className="items-center justify-between self-stretch">
|
||||||
|
@ -139,6 +142,22 @@ export function AnswerBetPanel(props: {
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{(betAmount ?? 0) > 10 &&
|
||||||
|
bankrollFraction >= 0.5 &&
|
||||||
|
bankrollFraction <= 1 ? (
|
||||||
|
<AlertBox
|
||||||
|
title="Whoa, there!"
|
||||||
|
text={`You might not want to spend ${formatPercent(
|
||||||
|
bankrollFraction
|
||||||
|
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
|
||||||
|
user?.balance ?? 0
|
||||||
|
)}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
|
||||||
<Col className="mt-3 w-full gap-3">
|
<Col className="mt-3 w-full gap-3">
|
||||||
<Row className="items-center justify-between text-sm">
|
<Row className="items-center justify-between text-sm">
|
||||||
<div className="text-gray-500">Probability</div>
|
<div className="text-gray-500">Probability</div>
|
||||||
|
|
|
@ -1,26 +1,23 @@
|
||||||
import { MAX_ANSWER_LENGTH } from 'common/answer'
|
import { MAX_ANSWER_LENGTH } from 'common/answer'
|
||||||
import { useState } from 'react'
|
|
||||||
import Textarea from 'react-expanding-textarea'
|
import Textarea from 'react-expanding-textarea'
|
||||||
import { XIcon } from '@heroicons/react/solid'
|
import { XIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
|
|
||||||
export function MultipleChoiceAnswers(props: {
|
export function MultipleChoiceAnswers(props: {
|
||||||
|
answers: string[]
|
||||||
setAnswers: (answers: string[]) => void
|
setAnswers: (answers: string[]) => void
|
||||||
}) {
|
}) {
|
||||||
const [answers, setInternalAnswers] = useState(['', '', ''])
|
const { answers, setAnswers } = props
|
||||||
|
|
||||||
const setAnswer = (i: number, answer: string) => {
|
const setAnswer = (i: number, answer: string) => {
|
||||||
const newAnswers = setElement(answers, i, answer)
|
const newAnswers = setElement(answers, i, answer)
|
||||||
setInternalAnswers(newAnswers)
|
setAnswers(newAnswers)
|
||||||
props.setAnswers(newAnswers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeAnswer = (i: number) => {
|
const removeAnswer = (i: number) => {
|
||||||
const newAnswers = answers.slice(0, i).concat(answers.slice(i + 1))
|
const newAnswers = answers.slice(0, i).concat(answers.slice(i + 1))
|
||||||
setInternalAnswers(newAnswers)
|
setAnswers(newAnswers)
|
||||||
props.setAnswers(newAnswers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addAnswer = () => setAnswer(answers.length, '')
|
const addAnswer = () => setAnswer(answers.length, '')
|
||||||
|
@ -28,7 +25,7 @@ export function MultipleChoiceAnswers(props: {
|
||||||
return (
|
return (
|
||||||
<Col>
|
<Col>
|
||||||
{answers.map((answer, i) => (
|
{answers.map((answer, i) => (
|
||||||
<Row className="mb-2 items-center align-middle">
|
<Row className="mb-2 items-center gap-2 align-middle">
|
||||||
{i + 1}.{' '}
|
{i + 1}.{' '}
|
||||||
<Textarea
|
<Textarea
|
||||||
value={answer}
|
value={answer}
|
||||||
|
@ -40,17 +37,22 @@ export function MultipleChoiceAnswers(props: {
|
||||||
/>
|
/>
|
||||||
{answers.length > 2 && (
|
{answers.length > 2 && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-xs btn-outline ml-2"
|
|
||||||
onClick={() => removeAnswer(i)}
|
onClick={() => removeAnswer(i)}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center rounded-full border border-gray-300 bg-white p-1 text-xs font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4 flex-shrink-0" />
|
<XIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Row className="justify-end">
|
<Row className="justify-end">
|
||||||
<button className="btn btn-outline btn-xs" onClick={addAnswer}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addAnswer}
|
||||||
|
className="inline-flex items-center rounded border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
Add answer
|
Add answer
|
||||||
</button>
|
</button>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -1,23 +1,24 @@
|
||||||
import { createContext, useEffect } from 'react'
|
import { ReactNode, createContext, useEffect } from 'react'
|
||||||
import { User } from 'common/user'
|
|
||||||
import { onIdTokenChanged } from 'firebase/auth'
|
import { onIdTokenChanged } from 'firebase/auth'
|
||||||
import {
|
import {
|
||||||
|
UserAndPrivateUser,
|
||||||
auth,
|
auth,
|
||||||
listenForUser,
|
listenForUser,
|
||||||
getUser,
|
listenForPrivateUser,
|
||||||
|
getUserAndPrivateUser,
|
||||||
setCachedReferralInfoForUser,
|
setCachedReferralInfoForUser,
|
||||||
} from 'web/lib/firebase/users'
|
} 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 { createUser } from 'web/lib/firebase/api'
|
||||||
import { randomString } from 'common/util/random'
|
import { randomString } from 'common/util/random'
|
||||||
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
||||||
import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
|
import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
|
||||||
|
|
||||||
// Either we haven't looked up the logged in user yet (undefined), or we know
|
// Either we haven't looked up the logged in user yet (undefined), or we know
|
||||||
// the user is not logged in (null), or we know the user is logged in (User).
|
// the user is not logged in (null), or we know the user is logged in.
|
||||||
type AuthUser = undefined | null | User
|
type AuthUser = undefined | null | UserAndPrivateUser
|
||||||
|
|
||||||
const CACHED_USER_KEY = 'CACHED_USER_KEY'
|
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
|
||||||
|
|
||||||
const ensureDeviceToken = () => {
|
const ensureDeviceToken = () => {
|
||||||
let deviceToken = localStorage.getItem('device-token')
|
let deviceToken = localStorage.getItem('device-token')
|
||||||
|
@ -28,48 +29,72 @@ const ensureDeviceToken = () => {
|
||||||
return deviceToken
|
return deviceToken
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthUser>(null)
|
export const AuthContext = createContext<AuthUser>(undefined)
|
||||||
|
|
||||||
export function AuthProvider({ children }: any) {
|
export function AuthProvider(props: {
|
||||||
const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(undefined)
|
children: ReactNode
|
||||||
|
serverUser?: AuthUser
|
||||||
|
}) {
|
||||||
|
const { children, serverUser } = props
|
||||||
|
const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(serverUser)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (serverUser === undefined) {
|
||||||
const cachedUser = localStorage.getItem(CACHED_USER_KEY)
|
const cachedUser = localStorage.getItem(CACHED_USER_KEY)
|
||||||
setAuthUser(cachedUser && JSON.parse(cachedUser))
|
setAuthUser(cachedUser && JSON.parse(cachedUser))
|
||||||
}, [setAuthUser])
|
}
|
||||||
|
}, [setAuthUser, serverUser])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return onIdTokenChanged(auth, async (fbUser) => {
|
return onIdTokenChanged(auth, async (fbUser) => {
|
||||||
if (fbUser) {
|
if (fbUser) {
|
||||||
setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken)
|
setTokenCookies({
|
||||||
let user = await getUser(fbUser.uid)
|
id: await fbUser.getIdToken(),
|
||||||
if (!user) {
|
refresh: fbUser.refreshToken,
|
||||||
|
})
|
||||||
|
let current = await getUserAndPrivateUser(fbUser.uid)
|
||||||
|
if (!current.user || !current.privateUser) {
|
||||||
const deviceToken = ensureDeviceToken()
|
const deviceToken = ensureDeviceToken()
|
||||||
user = (await createUser({ deviceToken })) as User
|
current = (await createUser({ deviceToken })) as UserAndPrivateUser
|
||||||
}
|
}
|
||||||
setAuthUser(user)
|
setAuthUser(current)
|
||||||
// Persist to local storage, to reduce login blink next time.
|
// Persist to local storage, to reduce login blink next time.
|
||||||
// Note: Cap on localStorage size is ~5mb
|
// Note: Cap on localStorage size is ~5mb
|
||||||
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(user))
|
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current))
|
||||||
setCachedReferralInfoForUser(user)
|
setCachedReferralInfoForUser(current.user)
|
||||||
} else {
|
} else {
|
||||||
// User logged out; reset to null
|
// User logged out; reset to null
|
||||||
deleteAuthCookies()
|
deleteTokenCookies()
|
||||||
setAuthUser(null)
|
setAuthUser(null)
|
||||||
localStorage.removeItem(CACHED_USER_KEY)
|
localStorage.removeItem(CACHED_USER_KEY)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [setAuthUser])
|
}, [setAuthUser])
|
||||||
|
|
||||||
const authUserId = authUser?.id
|
const uid = authUser?.user.id
|
||||||
const authUsername = authUser?.username
|
const username = authUser?.user.username
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authUserId && authUsername) {
|
if (uid && username) {
|
||||||
identifyUser(authUserId)
|
identifyUser(uid)
|
||||||
setUserProperty('username', authUsername)
|
setUserProperty('username', username)
|
||||||
return listenForUser(authUserId, setAuthUser)
|
const userListener = listenForUser(uid, (user) =>
|
||||||
|
setAuthUser((authUser) => {
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||||
|
return { ...authUser!, user: user! }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const privateUserListener = listenForPrivateUser(uid, (privateUser) => {
|
||||||
|
setAuthUser((authUser) => {
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||||
|
return { ...authUser!, privateUser: privateUser! }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
userListener()
|
||||||
|
privateUserListener()
|
||||||
}
|
}
|
||||||
}, [authUserId, authUsername, setAuthUser])
|
}
|
||||||
|
}, [uid, username, setAuthUser])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>
|
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import Router from 'next/router'
|
import Router from 'next/router'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { MouseEvent } from 'react'
|
import { MouseEvent, useState } from 'react'
|
||||||
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
|
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
export function Avatar(props: {
|
export function Avatar(props: {
|
||||||
|
@ -10,7 +10,8 @@ export function Avatar(props: {
|
||||||
size?: number | 'xs' | 'sm'
|
size?: number | 'xs' | 'sm'
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { username, avatarUrl, noLink, size, className } = props
|
const { username, noLink, size, className } = props
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
|
||||||
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
||||||
|
|
||||||
const onClick =
|
const onClick =
|
||||||
|
@ -35,6 +36,11 @@ export function Avatar(props: {
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
alt={username}
|
alt={username}
|
||||||
|
onError={() => {
|
||||||
|
// If the image doesn't load, clear the avatarUrl to show the default
|
||||||
|
// Mostly for localhost, when getting a 403 from googleusercontent
|
||||||
|
setAvatarUrl('')
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<UserCircleIcon
|
<UserCircleIcon
|
||||||
|
@ -47,14 +53,21 @@ export function Avatar(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyAvatar(props: { size?: number; multi?: boolean }) {
|
export function EmptyAvatar(props: {
|
||||||
const { size = 8, multi } = props
|
className?: string
|
||||||
|
size?: number
|
||||||
|
multi?: boolean
|
||||||
|
}) {
|
||||||
|
const { className, size = 8, multi } = props
|
||||||
const insize = size - 3
|
const insize = size - 3
|
||||||
const Icon = multi ? UsersIcon : UserIcon
|
const Icon = multi ? UsersIcon : UserIcon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-shrink-0 h-${size} w-${size} items-center justify-center rounded-full bg-gray-200`}
|
className={clsx(
|
||||||
|
`flex flex-shrink-0 h-${size} w-${size} items-center justify-center rounded-full bg-gray-200`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Icon className={`h-${insize} w-${insize} text-gray-500`} aria-hidden />
|
<Icon className={`h-${insize} w-${insize} text-gray-500`} aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -254,6 +254,7 @@ function BuyPanel(props: {
|
||||||
const resultProb = getCpmmProbability(newPool, newP)
|
const resultProb = getCpmmProbability(newPool, newP)
|
||||||
const probStayedSame =
|
const probStayedSame =
|
||||||
formatPercent(resultProb) === formatPercent(initialProb)
|
formatPercent(resultProb) === formatPercent(initialProb)
|
||||||
|
const probChange = Math.abs(resultProb - initialProb)
|
||||||
|
|
||||||
const currentPayout = newBet.shares
|
const currentPayout = newBet.shares
|
||||||
|
|
||||||
|
@ -305,6 +306,19 @@ function BuyPanel(props: {
|
||||||
''
|
''
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(betAmount ?? 0) > 10 && probChange >= 0.3 ? (
|
||||||
|
<AlertBox
|
||||||
|
title="Whoa, there!"
|
||||||
|
text={`Are you sure you want to move the market ${
|
||||||
|
isPseudoNumeric && contract.isLogScale
|
||||||
|
? 'this much'
|
||||||
|
: format(probChange)
|
||||||
|
}?`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
|
||||||
<Col className="mt-3 w-full gap-3">
|
<Col className="mt-3 w-full gap-3">
|
||||||
<Row className="items-center justify-between text-sm">
|
<Row className="items-center justify-between text-sm">
|
||||||
<div className="text-gray-500">
|
<div className="text-gray-500">
|
||||||
|
@ -434,8 +448,6 @@ function LimitOrderPanel(props: {
|
||||||
const yesAmount = shares * (yesLimitProb ?? 1)
|
const yesAmount = shares * (yesLimitProb ?? 1)
|
||||||
const noAmount = shares * (1 - (noLimitProb ?? 0))
|
const noAmount = shares * (1 - (noLimitProb ?? 0))
|
||||||
|
|
||||||
const profitIfBothFilled = shares - (yesAmount + noAmount)
|
|
||||||
|
|
||||||
function onBetChange(newAmount: number | undefined) {
|
function onBetChange(newAmount: number | undefined) {
|
||||||
setWasSubmitted(false)
|
setWasSubmitted(false)
|
||||||
setBetAmount(newAmount)
|
setBetAmount(newAmount)
|
||||||
|
@ -484,6 +496,8 @@ function LimitOrderPanel(props: {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
setWasSubmitted(true)
|
setWasSubmitted(true)
|
||||||
setBetAmount(undefined)
|
setBetAmount(undefined)
|
||||||
|
setLowLimitProb(undefined)
|
||||||
|
setHighLimitProb(undefined)
|
||||||
if (onBuySuccess) onBuySuccess()
|
if (onBuySuccess) onBuySuccess()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -543,6 +557,8 @@ function LimitOrderPanel(props: {
|
||||||
)
|
)
|
||||||
const noReturnPercent = formatPercent(noReturn)
|
const noReturnPercent = formatPercent(noReturn)
|
||||||
|
|
||||||
|
const profitIfBothFilled = shares - (yesAmount + noAmount) - yesFees - noFees
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={hidden ? 'hidden' : ''}>
|
<Col className={hidden ? 'hidden' : ''}>
|
||||||
<Row className="mt-1 items-center gap-4">
|
<Row className="mt-1 items-center gap-4">
|
||||||
|
|
|
@ -1,5 +1,14 @@
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
|
import {
|
||||||
|
Dictionary,
|
||||||
|
keyBy,
|
||||||
|
groupBy,
|
||||||
|
mapValues,
|
||||||
|
sortBy,
|
||||||
|
partition,
|
||||||
|
sumBy,
|
||||||
|
uniq,
|
||||||
|
} from 'lodash'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
@ -19,6 +28,7 @@ import {
|
||||||
Contract,
|
Contract,
|
||||||
contractPath,
|
contractPath,
|
||||||
getBinaryProbPercent,
|
getBinaryProbPercent,
|
||||||
|
getContractFromId,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { UserLink } from './user-page'
|
import { UserLink } from './user-page'
|
||||||
|
@ -41,10 +51,12 @@ import { trackLatency } from 'web/lib/firebase/tracking'
|
||||||
import { NumericContract } from 'common/contract'
|
import { NumericContract } from 'common/contract'
|
||||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import { useUserBets } from 'web/hooks/use-user-bets'
|
||||||
import { SellSharesModal } from './sell-modal'
|
import { SellSharesModal } from './sell-modal'
|
||||||
import { useUnfilledBets } from 'web/hooks/use-bets'
|
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||||
import { LimitBet } from 'common/bet'
|
import { LimitBet } from 'common/bet'
|
||||||
import { floatingEqual } from 'common/util/math'
|
import { floatingEqual } from 'common/util/math'
|
||||||
|
import { filterDefined } from 'common/util/array'
|
||||||
import { Pagination } from './pagination'
|
import { Pagination } from './pagination'
|
||||||
import { LimitOrderTable } from './limit-bets'
|
import { LimitOrderTable } from './limit-bets'
|
||||||
|
|
||||||
|
@ -52,25 +64,35 @@ type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
|
||||||
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
|
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
|
||||||
|
|
||||||
const CONTRACTS_PER_PAGE = 50
|
const CONTRACTS_PER_PAGE = 50
|
||||||
|
const JUNE_1_2022 = new Date('2022-06-01T00:00:00.000Z').valueOf()
|
||||||
|
|
||||||
export function BetsList(props: {
|
export function BetsList(props: { user: User }) {
|
||||||
user: User
|
const { user } = props
|
||||||
bets: Bet[] | undefined
|
|
||||||
contractsById: { [id: string]: Contract } | undefined
|
|
||||||
hideBetsBefore?: number
|
|
||||||
}) {
|
|
||||||
const { user, bets: allBets, contractsById, hideBetsBefore } = props
|
|
||||||
|
|
||||||
const signedInUser = useUser()
|
const signedInUser = useUser()
|
||||||
const isYourBets = user.id === signedInUser?.id
|
const isYourBets = user.id === signedInUser?.id
|
||||||
|
const hideBetsBefore = isYourBets ? 0 : JUNE_1_2022
|
||||||
|
const userBets = useUserBets(user.id, { includeRedemptions: true })
|
||||||
|
const [contractsById, setContractsById] = useState<
|
||||||
|
Dictionary<Contract> | undefined
|
||||||
|
>()
|
||||||
|
|
||||||
// Hide bets before 06-01-2022 if this isn't your own profile
|
// Hide bets before 06-01-2022 if this isn't your own profile
|
||||||
// NOTE: This means public profits also begin on 06-01-2022 as well.
|
// NOTE: This means public profits also begin on 06-01-2022 as well.
|
||||||
const bets = useMemo(
|
const bets = useMemo(
|
||||||
() => allBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)),
|
() => userBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)),
|
||||||
[allBets, hideBetsBefore]
|
[userBets, hideBetsBefore]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bets) {
|
||||||
|
const contractIds = uniq(bets.map((b) => b.contractId))
|
||||||
|
Promise.all(contractIds.map(getContractFromId)).then((contracts) => {
|
||||||
|
setContractsById(keyBy(filterDefined(contracts), 'id'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [bets])
|
||||||
|
|
||||||
const [sort, setSort] = useState<BetSort>('newest')
|
const [sort, setSort] = useState<BetSort>('newest')
|
||||||
const [filter, setFilter] = useState<BetFilter>('open')
|
const [filter, setFilter] = useState<BetFilter>('open')
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
|
@ -406,76 +428,35 @@ export function BetsSummary(props: {
|
||||||
: 'NO'
|
: 'NO'
|
||||||
: 'YES'
|
: 'YES'
|
||||||
|
|
||||||
return (
|
const canSell =
|
||||||
<Row className={clsx('flex-wrap gap-4 sm:flex-nowrap sm:gap-6', className)}>
|
isYourBets &&
|
||||||
{!isCpmm && (
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Invested
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
{resolution ? (
|
|
||||||
<Col>
|
|
||||||
<div className="text-sm text-gray-500">Payout</div>
|
|
||||||
<div className="whitespace-nowrap">
|
|
||||||
{formatMoney(payout)} <ProfitBadge profitPercent={profitPercent} />
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
) : isBinary ? (
|
|
||||||
<>
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Payout if <YesLabel />
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">{formatMoney(yesWinnings)}</div>
|
|
||||||
</Col>
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Payout if <NoLabel />
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
|
||||||
</Col>
|
|
||||||
</>
|
|
||||||
) : isPseudoNumeric ? (
|
|
||||||
<>
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Payout if {'>='} {formatLargeNumber(contract.max)}
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">{formatMoney(yesWinnings)}</div>
|
|
||||||
</Col>
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Payout if {'<='} {formatLargeNumber(contract.min)}
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
|
||||||
</Col>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Current value
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
|
|
||||||
<div className="whitespace-nowrap">
|
|
||||||
{formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} />
|
|
||||||
{isYourBets &&
|
|
||||||
isCpmm &&
|
isCpmm &&
|
||||||
(isBinary || isPseudoNumeric) &&
|
(isBinary || isPseudoNumeric) &&
|
||||||
!isClosed &&
|
!isClosed &&
|
||||||
!resolution &&
|
!resolution &&
|
||||||
hasShares &&
|
hasShares &&
|
||||||
sharesOutcome &&
|
sharesOutcome &&
|
||||||
user && (
|
user
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col className={clsx(className, 'gap-4')}>
|
||||||
|
<Row className="flex-wrap gap-4 sm:flex-nowrap sm:gap-6">
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Invested
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
{formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
{canSell && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm ml-2"
|
className="btn btn-sm self-end"
|
||||||
onClick={() => setShowSellModal(true)}
|
onClick={() => setShowSellModal(true)}
|
||||||
>
|
>
|
||||||
Sell
|
Sell
|
||||||
|
@ -492,9 +473,60 @@ export function BetsSummary(props: {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</Row>
|
||||||
|
<Row className="flex-wrap-none gap-4">
|
||||||
|
{resolution ? (
|
||||||
|
<Col>
|
||||||
|
<div className="text-sm text-gray-500">Payout</div>
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
{formatMoney(payout)}{' '}
|
||||||
|
<ProfitBadge profitPercent={profitPercent} />
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
) : isBinary ? (
|
||||||
|
<>
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Payout if <YesLabel />
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
{formatMoney(yesWinnings)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Payout if <NoLabel />
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
||||||
|
</Col>
|
||||||
|
</>
|
||||||
|
) : isPseudoNumeric ? (
|
||||||
|
<>
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Payout if {'>='} {formatLargeNumber(contract.max)}
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
{formatMoney(yesWinnings)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Payout if {'<='} {formatLargeNumber(contract.min)}
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
||||||
|
</Col>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Current value
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,8 @@ import { FIXED_ANTE } from 'common/antes'
|
||||||
import Textarea from 'react-expanding-textarea'
|
import Textarea from 'react-expanding-textarea'
|
||||||
import { useTextEditor } from 'web/components/editor'
|
import { useTextEditor } from 'web/components/editor'
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
|
||||||
|
|
||||||
type challengeInfo = {
|
type challengeInfo = {
|
||||||
amount: number
|
amount: number
|
||||||
|
@ -77,7 +79,14 @@ export function CreateChallengeModal(props: {
|
||||||
outcome: newChallenge.outcome,
|
outcome: newChallenge.outcome,
|
||||||
contract: challengeContract as BinaryContract,
|
contract: challengeContract as BinaryContract,
|
||||||
})
|
})
|
||||||
challenge && setChallengeSlug(getChallengeUrl(challenge))
|
if (challenge) {
|
||||||
|
setChallengeSlug(getChallengeUrl(challenge))
|
||||||
|
track('challenge created', {
|
||||||
|
creator: user.username,
|
||||||
|
amount: newChallenge.amount,
|
||||||
|
contractId: contract.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("couldn't create market/challenge:", e)
|
console.error("couldn't create market/challenge:", e)
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,10 @@ export function ChoicesToggleGroup(props: {
|
||||||
} = props
|
} = props
|
||||||
return (
|
return (
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
className={clsx(className, 'flex flex-row flex-wrap items-center gap-3')}
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-row flex-wrap items-center gap-2 sm:gap-3'
|
||||||
|
)}
|
||||||
value={currentChoice.toString()}
|
value={currentChoice.toString()}
|
||||||
onChange={setChoice}
|
onChange={setChoice}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { Comment } from 'common/comment'
|
import { Comment } from 'common/comment'
|
||||||
import { Contract } from 'common/contract'
|
import { groupConsecutive } from 'common/util/array'
|
||||||
import { contractPath } from 'web/lib/firebase/contracts'
|
import { getUsersComments } from 'web/lib/firebase/comments'
|
||||||
import { SiteLink } from './site-link'
|
import { SiteLink } from './site-link'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { Avatar } from './avatar'
|
import { Avatar } from './avatar'
|
||||||
|
@ -8,49 +10,82 @@ import { RelativeTimestamp } from './relative-timestamp'
|
||||||
import { UserLink } from './user-page'
|
import { UserLink } from './user-page'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Linkify } from './linkify'
|
import { Content } from './editor'
|
||||||
import { groupBy } from 'lodash'
|
import { Pagination } from './pagination'
|
||||||
|
import { LoadingIndicator } from './loading-indicator'
|
||||||
|
|
||||||
export function UserCommentsList(props: {
|
const COMMENTS_PER_PAGE = 50
|
||||||
user: User
|
|
||||||
comments: Comment[]
|
|
||||||
contractsById: { [id: string]: Contract }
|
|
||||||
}) {
|
|
||||||
const { comments, contractsById } = props
|
|
||||||
|
|
||||||
|
type ContractComment = Comment & {
|
||||||
|
contractId: string
|
||||||
|
contractSlug: string
|
||||||
|
contractQuestion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function contractPath(slug: string) {
|
||||||
|
// by convention this includes the contract creator username, but we don't
|
||||||
|
// have that handy, so we just put /market/
|
||||||
|
return `/market/${slug}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserCommentsList(props: { user: User }) {
|
||||||
|
const { user } = props
|
||||||
|
const [comments, setComments] = useState<ContractComment[] | undefined>()
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const start = page * COMMENTS_PER_PAGE
|
||||||
|
const end = start + COMMENTS_PER_PAGE
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUsersComments(user.id).then((cs) => {
|
||||||
// we don't show comments in groups here atm, just comments on contracts
|
// we don't show comments in groups here atm, just comments on contracts
|
||||||
const contractComments = comments.filter((c) => c.contractId)
|
setComments(cs.filter((c) => c.contractId) as ContractComment[])
|
||||||
const commentsByContract = groupBy(contractComments, 'contractId')
|
})
|
||||||
|
}, [user.id])
|
||||||
|
|
||||||
|
if (comments == null) {
|
||||||
|
return <LoadingIndicator />
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageComments = groupConsecutive(comments.slice(start, end), (c) => {
|
||||||
|
return { question: c.contractQuestion, slug: c.contractSlug }
|
||||||
|
})
|
||||||
return (
|
return (
|
||||||
<Col className={'bg-white'}>
|
<Col className={'bg-white'}>
|
||||||
{Object.entries(commentsByContract).map(([contractId, comments]) => {
|
{pageComments.map(({ key, items }, i) => {
|
||||||
const contract = contractsById[contractId]
|
|
||||||
return (
|
return (
|
||||||
<div key={contractId} className={'border-width-1 border-b p-5'}>
|
<div key={start + i} className="border-b p-5">
|
||||||
<SiteLink
|
<SiteLink
|
||||||
className={'mb-2 block text-sm text-indigo-700'}
|
className="mb-2 block pb-2 font-medium text-indigo-700"
|
||||||
href={contractPath(contract)}
|
href={contractPath(key.slug)}
|
||||||
>
|
>
|
||||||
{contract.question}
|
{key.question}
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
{comments.map((comment) => (
|
<Col className="gap-6">
|
||||||
|
{items.map((comment) => (
|
||||||
<ProfileComment
|
<ProfileComment
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
comment={comment}
|
comment={comment}
|
||||||
className="relative flex items-start space-x-3 pb-6"
|
className="relative flex items-start space-x-3"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</Col>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
itemsPerPage={COMMENTS_PER_PAGE}
|
||||||
|
totalItems={comments.length}
|
||||||
|
setPage={setPage}
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileComment(props: { comment: Comment; className?: string }) {
|
function ProfileComment(props: { comment: Comment; className?: string }) {
|
||||||
const { comment, className } = props
|
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
|
// TODO: find and attach relevant bets by comment betId at some point
|
||||||
return (
|
return (
|
||||||
<Row className={className}>
|
<Row className={className}>
|
||||||
|
@ -64,7 +99,7 @@ function ProfileComment(props: { comment: Comment; className?: string }) {
|
||||||
/>{' '}
|
/>{' '}
|
||||||
<RelativeTimestamp time={createdTime} />
|
<RelativeTimestamp time={createdTime} />
|
||||||
</p>
|
</p>
|
||||||
<Linkify text={text} />
|
<Content content={content || text} smallImage />
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,26 +1,43 @@
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import algoliasearch from 'algoliasearch/lite'
|
import algoliasearch, { SearchIndex } from 'algoliasearch/lite'
|
||||||
|
import { SearchOptions } from '@algolia/client-search'
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params'
|
import { User } from 'common/user'
|
||||||
|
import { Sort, useQuery, useSort } from '../hooks/use-sort-and-query-params'
|
||||||
import {
|
import {
|
||||||
ContractHighlightOptions,
|
ContractHighlightOptions,
|
||||||
ContractsGrid,
|
ContractsGrid,
|
||||||
} from './contract/contracts-list'
|
} from './contract/contracts-grid'
|
||||||
|
import { ShowTime } from './contract/contract-details'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useRef, useMemo, useState } from 'react'
|
||||||
import { Spacer } from './layout/spacer'
|
import { unstable_batchedUpdates } from 'react-dom'
|
||||||
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
|
||||||
import { useFollows } from 'web/hooks/use-follows'
|
import { useFollows } from 'web/hooks/use-follows'
|
||||||
import { track, trackCallback } from 'web/lib/service/analytics'
|
import { track, trackCallback } from 'web/lib/service/analytics'
|
||||||
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
|
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
|
||||||
import { useMemberGroups } from 'web/hooks/use-group'
|
import { useMemberGroups } from 'web/hooks/use-group'
|
||||||
import { Group, NEW_USER_GROUP_SLUGS } from 'common/group'
|
import { NEW_USER_GROUP_SLUGS } from 'common/group'
|
||||||
import { PillButton } from './buttons/pill-button'
|
import { PillButton } from './buttons/pill-button'
|
||||||
import { range, sortBy } from 'lodash'
|
import { debounce, sortBy } from 'lodash'
|
||||||
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
|
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
|
import { safeLocalStorage } from 'web/lib/util/local'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
// TODO: this obviously doesn't work with SSR, common sense would suggest
|
||||||
|
// that we should save things like this in cookies so the server has them
|
||||||
|
|
||||||
|
const MARKETS_SORT = 'markets_sort'
|
||||||
|
|
||||||
|
function setSavedSort(s: Sort) {
|
||||||
|
safeLocalStorage()?.setItem(MARKETS_SORT, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSavedSort() {
|
||||||
|
return safeLocalStorage()?.getItem(MARKETS_SORT) as Sort | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
const searchClient = algoliasearch(
|
const searchClient = algoliasearch(
|
||||||
'GJQPAYENIF',
|
'GJQPAYENIF',
|
||||||
|
@ -40,44 +57,176 @@ const sortOptions = [
|
||||||
{ label: 'Close date', value: 'close-date' },
|
{ label: 'Close date', value: 'close-date' },
|
||||||
{ label: 'Resolve date', value: 'resolve-date' },
|
{ label: 'Resolve date', value: 'resolve-date' },
|
||||||
]
|
]
|
||||||
export const DEFAULT_SORT = 'score'
|
|
||||||
|
|
||||||
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
|
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
|
||||||
|
|
||||||
export function ContractSearch(props: {
|
type SearchParameters = {
|
||||||
querySortOptions?: {
|
index: SearchIndex
|
||||||
defaultSort: Sort
|
query: string
|
||||||
defaultFilter?: filter
|
numericFilters: SearchOptions['numericFilters']
|
||||||
shouldLoadFromStorage?: boolean
|
facetFilters: SearchOptions['facetFilters']
|
||||||
|
showTime?: ShowTime
|
||||||
}
|
}
|
||||||
additionalFilter?: {
|
|
||||||
|
type AdditionalFilter = {
|
||||||
creatorId?: string
|
creatorId?: string
|
||||||
tag?: string
|
tag?: string
|
||||||
excludeContractIds?: string[]
|
excludeContractIds?: string[]
|
||||||
groupSlug?: string
|
groupSlug?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ContractSearch(props: {
|
||||||
|
user?: User | null
|
||||||
|
defaultSort?: Sort
|
||||||
|
defaultFilter?: filter
|
||||||
|
additionalFilter?: AdditionalFilter
|
||||||
highlightOptions?: ContractHighlightOptions
|
highlightOptions?: ContractHighlightOptions
|
||||||
onContractClick?: (contract: Contract) => void
|
onContractClick?: (contract: Contract) => void
|
||||||
showPlaceHolder?: boolean
|
|
||||||
hideOrderSelector?: boolean
|
hideOrderSelector?: boolean
|
||||||
overrideGridClassName?: string
|
overrideGridClassName?: string
|
||||||
cardHideOptions?: {
|
cardHideOptions?: {
|
||||||
hideGroupLink?: boolean
|
hideGroupLink?: boolean
|
||||||
hideQuickBet?: boolean
|
hideQuickBet?: boolean
|
||||||
}
|
}
|
||||||
|
headerClassName?: string
|
||||||
|
useQuerySortLocalStorage?: boolean
|
||||||
|
useQuerySortUrlParams?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
querySortOptions,
|
user,
|
||||||
|
defaultSort,
|
||||||
|
defaultFilter,
|
||||||
additionalFilter,
|
additionalFilter,
|
||||||
onContractClick,
|
onContractClick,
|
||||||
overrideGridClassName,
|
overrideGridClassName,
|
||||||
hideOrderSelector,
|
hideOrderSelector,
|
||||||
showPlaceHolder,
|
|
||||||
cardHideOptions,
|
cardHideOptions,
|
||||||
highlightOptions,
|
highlightOptions,
|
||||||
|
headerClassName,
|
||||||
|
useQuerySortLocalStorage,
|
||||||
|
useQuerySortUrlParams,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const user = useUser()
|
const [numPages, setNumPages] = useState(1)
|
||||||
|
const [pages, setPages] = useState<Contract[][]>([])
|
||||||
|
const [showTime, setShowTime] = useState<ShowTime | undefined>()
|
||||||
|
|
||||||
|
const searchParameters = useRef<SearchParameters | undefined>()
|
||||||
|
const requestId = useRef(0)
|
||||||
|
|
||||||
|
const performQuery = async (freshQuery?: boolean) => {
|
||||||
|
if (searchParameters.current === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const params = searchParameters.current
|
||||||
|
const id = ++requestId.current
|
||||||
|
const requestedPage = freshQuery ? 0 : pages.length
|
||||||
|
if (freshQuery || requestedPage < numPages) {
|
||||||
|
const results = await params.index.search(params.query, {
|
||||||
|
facetFilters: params.facetFilters,
|
||||||
|
numericFilters: params.numericFilters,
|
||||||
|
page: requestedPage,
|
||||||
|
hitsPerPage: 20,
|
||||||
|
})
|
||||||
|
// if there's a more recent request, forget about this one
|
||||||
|
if (id === requestId.current) {
|
||||||
|
const newPage = results.hits as any as Contract[]
|
||||||
|
// this spooky looking function is the easiest way to get react to
|
||||||
|
// batch this and not do multiple renders. we can throw it out in react 18.
|
||||||
|
// see https://github.com/reactwg/react-18/discussions/21
|
||||||
|
unstable_batchedUpdates(() => {
|
||||||
|
setShowTime(params.showTime)
|
||||||
|
setNumPages(results.nbPages)
|
||||||
|
if (freshQuery) {
|
||||||
|
setPages([newPage])
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
} else {
|
||||||
|
setPages((pages) => [...pages, newPage])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSearchParametersChanged = useRef(
|
||||||
|
debounce((params) => {
|
||||||
|
searchParameters.current = params
|
||||||
|
performQuery(true)
|
||||||
|
}, 100)
|
||||||
|
).current
|
||||||
|
|
||||||
|
const contracts = pages
|
||||||
|
.flat()
|
||||||
|
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
|
||||||
|
|
||||||
|
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||||
|
return <ContractSearchFirestore additionalFilter={additionalFilter} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col className="h-full">
|
||||||
|
<ContractSearchControls
|
||||||
|
className={headerClassName}
|
||||||
|
defaultSort={defaultSort}
|
||||||
|
defaultFilter={defaultFilter}
|
||||||
|
additionalFilter={additionalFilter}
|
||||||
|
hideOrderSelector={hideOrderSelector}
|
||||||
|
useQuerySortLocalStorage={useQuerySortLocalStorage}
|
||||||
|
useQuerySortUrlParams={useQuerySortUrlParams}
|
||||||
|
user={user}
|
||||||
|
onSearchParametersChanged={onSearchParametersChanged}
|
||||||
|
/>
|
||||||
|
<ContractsGrid
|
||||||
|
contracts={pages.length === 0 ? undefined : contracts}
|
||||||
|
loadMore={performQuery}
|
||||||
|
showTime={showTime}
|
||||||
|
onContractClick={onContractClick}
|
||||||
|
overrideGridClassName={overrideGridClassName}
|
||||||
|
highlightOptions={highlightOptions}
|
||||||
|
cardHideOptions={cardHideOptions}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContractSearchControls(props: {
|
||||||
|
className?: string
|
||||||
|
defaultSort?: Sort
|
||||||
|
defaultFilter?: filter
|
||||||
|
additionalFilter?: AdditionalFilter
|
||||||
|
hideOrderSelector?: boolean
|
||||||
|
onSearchParametersChanged: (params: SearchParameters) => void
|
||||||
|
useQuerySortLocalStorage?: boolean
|
||||||
|
useQuerySortUrlParams?: boolean
|
||||||
|
user?: User | null
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
className,
|
||||||
|
defaultSort,
|
||||||
|
defaultFilter,
|
||||||
|
additionalFilter,
|
||||||
|
hideOrderSelector,
|
||||||
|
onSearchParametersChanged,
|
||||||
|
useQuerySortLocalStorage,
|
||||||
|
useQuerySortUrlParams,
|
||||||
|
user,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const savedSort = useQuerySortLocalStorage ? getSavedSort() : null
|
||||||
|
const initialSort = savedSort ?? defaultSort ?? 'score'
|
||||||
|
const querySortOpts = { useUrl: !!useQuerySortUrlParams }
|
||||||
|
const [sort, setSort] = useSort(initialSort, querySortOpts)
|
||||||
|
const [query, setQuery] = useQuery('', querySortOpts)
|
||||||
|
const [filter, setFilter] = useState<filter>(defaultFilter ?? 'open')
|
||||||
|
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (useQuerySortLocalStorage) {
|
||||||
|
setSavedSort(sort)
|
||||||
|
}
|
||||||
|
}, [sort])
|
||||||
|
|
||||||
|
const follows = useFollows(user?.id)
|
||||||
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
|
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
|
||||||
(group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
|
(group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
|
||||||
)
|
)
|
||||||
|
@ -91,31 +240,8 @@ export function ContractSearch(props: {
|
||||||
(group) => group.contractIds.length
|
(group) => group.contractIds.length
|
||||||
).reverse()
|
).reverse()
|
||||||
|
|
||||||
const defaultPillGroups = DEFAULT_CATEGORY_GROUPS as Group[]
|
const pillGroups: { name: string; slug: string }[] =
|
||||||
|
memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS
|
||||||
const pillGroups =
|
|
||||||
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
|
|
||||||
|
|
||||||
const follows = useFollows(user?.id)
|
|
||||||
|
|
||||||
const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {}
|
|
||||||
const { query, setQuery, sort, setSort } = useQueryAndSortParams({
|
|
||||||
defaultSort,
|
|
||||||
shouldLoadFromStorage,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [filter, setFilter] = useState<filter>(
|
|
||||||
querySortOptions?.defaultFilter ?? 'open'
|
|
||||||
)
|
|
||||||
const pillsEnabled = !additionalFilter && !query
|
|
||||||
|
|
||||||
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
|
|
||||||
|
|
||||||
const selectPill = (pill: string | undefined) => () => {
|
|
||||||
setPillFilter(pill)
|
|
||||||
setPage(0)
|
|
||||||
track('select search category', { category: pill ?? 'all' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const additionalFilters = [
|
const additionalFilters = [
|
||||||
additionalFilter?.creatorId
|
additionalFilter?.creatorId
|
||||||
|
@ -162,6 +288,27 @@ export function ContractSearch(props: {
|
||||||
filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
|
filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
|
||||||
].filter((f) => f)
|
].filter((f) => f)
|
||||||
|
|
||||||
|
const selectPill = (pill: string | undefined) => () => {
|
||||||
|
setPillFilter(pill)
|
||||||
|
track('select search category', { category: pill ?? 'all' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateQuery = (newQuery: string) => {
|
||||||
|
setQuery(newQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectFilter = (newFilter: filter) => {
|
||||||
|
if (newFilter === filter) return
|
||||||
|
setFilter(newFilter)
|
||||||
|
track('select search filter', { filter: newFilter })
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectSort = (newSort: Sort) => {
|
||||||
|
if (newSort === sort) return
|
||||||
|
setSort(newSort)
|
||||||
|
track('select search sort', { sort: newSort })
|
||||||
|
}
|
||||||
|
|
||||||
const indexName = `${indexPrefix}contracts-${sort}`
|
const indexName = `${indexPrefix}contracts-${sort}`
|
||||||
const index = useMemo(() => searchClient.initIndex(indexName), [indexName])
|
const index = useMemo(() => searchClient.initIndex(indexName), [indexName])
|
||||||
const searchIndex = useMemo(
|
const searchIndex = useMemo(
|
||||||
|
@ -169,100 +316,28 @@ export function ContractSearch(props: {
|
||||||
[searchIndexName]
|
[searchIndexName]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [page, setPage] = useState(0)
|
|
||||||
const [numPages, setNumPages] = useState(1)
|
|
||||||
const [hitsByPage, setHitsByPage] = useState<{ [page: string]: Contract[] }>(
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let wasMostRecentQuery = true
|
onSearchParametersChanged({
|
||||||
const algoliaIndex = query ? searchIndex : index
|
index: query ? searchIndex : index,
|
||||||
|
query: query,
|
||||||
algoliaIndex
|
numericFilters: numericFilters,
|
||||||
.search(query, {
|
facetFilters: facetFilters,
|
||||||
facetFilters,
|
showTime:
|
||||||
numericFilters,
|
sort === 'close-date' || sort === 'resolve-date' ? sort : undefined,
|
||||||
page,
|
|
||||||
hitsPerPage: 20,
|
|
||||||
})
|
})
|
||||||
.then((results) => {
|
}, [query, index, searchIndex, filter, JSON.stringify(facetFilters)])
|
||||||
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 (
|
|
||||||
<ContractSearchFirestore
|
|
||||||
querySortOptions={querySortOptions}
|
|
||||||
additionalFilter={additionalFilter}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col>
|
<Col
|
||||||
|
className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)}
|
||||||
|
>
|
||||||
<Row className="gap-1 sm:gap-2">
|
<Row className="gap-1 sm:gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => updateQuery(e.target.value)}
|
onChange={(e) => updateQuery(e.target.value)}
|
||||||
placeholder={showPlaceHolder ? `Search ${filter} markets` : ''}
|
onBlur={trackCallback('search', { query })}
|
||||||
|
placeholder={'Search'}
|
||||||
className="input input-bordered w-full"
|
className="input input-bordered w-full"
|
||||||
/>
|
/>
|
||||||
{!query && (
|
{!query && (
|
||||||
|
@ -292,9 +367,7 @@ export function ContractSearch(props: {
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Spacer h={3} />
|
{!additionalFilter && !query && (
|
||||||
|
|
||||||
{pillsEnabled && (
|
|
||||||
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
|
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
|
||||||
<PillButton
|
<PillButton
|
||||||
key={'all'}
|
key={'all'}
|
||||||
|
@ -334,25 +407,6 @@ export function ContractSearch(props: {
|
||||||
})}
|
})}
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Spacer h={3} />
|
|
||||||
|
|
||||||
{filter === 'personal' &&
|
|
||||||
(follows ?? []).length === 0 &&
|
|
||||||
memberGroupSlugs.length === 0 ? (
|
|
||||||
<>You're not following anyone, nor in any of your own groups yet.</>
|
|
||||||
) : (
|
|
||||||
<ContractsGrid
|
|
||||||
contracts={contracts}
|
|
||||||
loadMore={loadMore}
|
|
||||||
hasMore={true}
|
|
||||||
showTime={showTime}
|
|
||||||
onContractClick={onContractClick}
|
|
||||||
overrideGridClassName={overrideGridClassName}
|
|
||||||
highlightOptions={highlightOptions}
|
|
||||||
cardHideOptions={cardHideOptions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,6 +31,7 @@ import { useUser } from 'web/hooks/use-user'
|
||||||
import { track } from '@amplitude/analytics-browser'
|
import { track } from '@amplitude/analytics-browser'
|
||||||
import { trackCallback } from 'web/lib/service/analytics'
|
import { trackCallback } from 'web/lib/service/analytics'
|
||||||
import { getMappedValue } from 'common/pseudo-numeric'
|
import { getMappedValue } from 'common/pseudo-numeric'
|
||||||
|
import { Tooltip } from '../tooltip'
|
||||||
|
|
||||||
export function ContractCard(props: {
|
export function ContractCard(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -65,20 +66,13 @@ export function ContractCard(props: {
|
||||||
!hideQuickBet
|
!hideQuickBet
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Row
|
||||||
<Col
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'relative gap-3 rounded-lg bg-white py-4 pl-6 pr-5 shadow-md hover:cursor-pointer hover:bg-gray-100',
|
'relative gap-3 self-start rounded-lg bg-white shadow-md hover:cursor-pointer hover:bg-gray-100',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Row>
|
<Col className="group relative flex-1 gap-3 py-4 pb-12 pl-6">
|
||||||
<Col className="relative flex-1 gap-3 pr-1">
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
'peer absolute -left-6 -top-4 -bottom-4 right-0 z-10'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{onClick ? (
|
{onClick ? (
|
||||||
<a
|
<a
|
||||||
className="absolute top-0 left-0 right-0 bottom-0"
|
className="absolute top-0 left-0 right-0 bottom-0"
|
||||||
|
@ -106,10 +100,12 @@ export function ContractCard(props: {
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
<AvatarDetails
|
||||||
<AvatarDetails contract={contract} />
|
contract={contract}
|
||||||
|
className={'hidden md:inline-flex'}
|
||||||
|
/>
|
||||||
<p
|
<p
|
||||||
className="break-words font-semibold text-indigo-700 peer-hover:underline peer-hover:decoration-indigo-400 peer-hover:decoration-2"
|
className="break-words font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2"
|
||||||
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
|
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
|
||||||
>
|
>
|
||||||
{question}
|
{question}
|
||||||
|
@ -126,35 +122,28 @@ export function ContractCard(props: {
|
||||||
) : (
|
) : (
|
||||||
<FreeResponseTopAnswer contract={contract} truncate="long" />
|
<FreeResponseTopAnswer contract={contract} truncate="long" />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<MiscDetails
|
|
||||||
contract={contract}
|
|
||||||
showHotVolume={showHotVolume}
|
|
||||||
showTime={showTime}
|
|
||||||
hideGroupLink={hideGroupLink}
|
|
||||||
/>
|
|
||||||
</Col>
|
</Col>
|
||||||
{showQuickBet ? (
|
{showQuickBet ? (
|
||||||
<QuickBet contract={contract} user={user} />
|
<QuickBet contract={contract} user={user} />
|
||||||
) : (
|
) : (
|
||||||
<Col className="m-auto pl-2">
|
<>
|
||||||
{outcomeType === 'BINARY' && (
|
{outcomeType === 'BINARY' && (
|
||||||
<BinaryResolutionOrChance
|
<BinaryResolutionOrChance
|
||||||
className="items-center"
|
className="items-center self-center pr-5"
|
||||||
contract={contract}
|
contract={contract}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{outcomeType === 'PSEUDO_NUMERIC' && (
|
{outcomeType === 'PSEUDO_NUMERIC' && (
|
||||||
<PseudoNumericResolutionOrExpectation
|
<PseudoNumericResolutionOrExpectation
|
||||||
className="items-center"
|
className="items-center self-center pr-5"
|
||||||
contract={contract}
|
contract={contract}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{outcomeType === 'NUMERIC' && (
|
{outcomeType === 'NUMERIC' && (
|
||||||
<NumericResolutionOrExpectation
|
<NumericResolutionOrExpectation
|
||||||
className="items-center"
|
className="items-center self-center pr-5"
|
||||||
contract={contract}
|
contract={contract}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -162,17 +151,33 @@ export function ContractCard(props: {
|
||||||
{(outcomeType === 'FREE_RESPONSE' ||
|
{(outcomeType === 'FREE_RESPONSE' ||
|
||||||
outcomeType === 'MULTIPLE_CHOICE') && (
|
outcomeType === 'MULTIPLE_CHOICE') && (
|
||||||
<FreeResponseResolutionOrChance
|
<FreeResponseResolutionOrChance
|
||||||
className="self-end text-gray-600"
|
className="items-center self-center pr-5 text-gray-600"
|
||||||
contract={contract}
|
contract={contract}
|
||||||
truncate="long"
|
truncate="long"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ProbBar contract={contract} />
|
<ProbBar contract={contract} />
|
||||||
</Col>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<Row
|
||||||
|
className={clsx(
|
||||||
|
'absolute bottom-3 gap-2 truncate px-5 md:gap-0',
|
||||||
|
showQuickBet ? 'w-[85%]' : 'w-full'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AvatarDetails
|
||||||
|
contract={contract}
|
||||||
|
short={true}
|
||||||
|
className={'block md:hidden'}
|
||||||
|
/>
|
||||||
|
<MiscDetails
|
||||||
|
contract={contract}
|
||||||
|
showHotVolume={showHotVolume}
|
||||||
|
showTime={showTime}
|
||||||
|
hideGroupLink={hideGroupLink}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -332,22 +337,19 @@ export function PseudoNumericResolutionOrExpectation(props: {
|
||||||
{resolution === 'CANCEL' ? (
|
{resolution === 'CANCEL' ? (
|
||||||
<CancelLabel />
|
<CancelLabel />
|
||||||
) : (
|
) : (
|
||||||
<div
|
<Tooltip className={textColor} text={value.toFixed(2)}>
|
||||||
className={clsx('tooltip', textColor)}
|
|
||||||
data-tip={value.toFixed(2)}
|
|
||||||
>
|
|
||||||
{formatLargeNumber(value)}
|
{formatLargeNumber(value)}
|
||||||
</div>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div
|
<Tooltip
|
||||||
className={clsx('tooltip text-3xl', textColor)}
|
className={clsx('text-3xl', textColor)}
|
||||||
data-tip={value.toFixed(2)}
|
text={value.toFixed(2)}
|
||||||
>
|
>
|
||||||
{formatLargeNumber(value)}
|
{formatLargeNumber(value)}
|
||||||
</div>
|
</Tooltip>
|
||||||
<div className={clsx('text-base', textColor)}>expected</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 { Button } from '../button'
|
||||||
import { Spacer } from '../layout/spacer'
|
import { Spacer } from '../layout/spacer'
|
||||||
import { Editor, Content as ContentType } from '@tiptap/react'
|
import { Editor, Content as ContentType } from '@tiptap/react'
|
||||||
|
import { insertContent } from '../editor/utils'
|
||||||
|
|
||||||
export function ContractDescription(props: {
|
export function ContractDescription(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -94,12 +95,8 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {
|
||||||
size="xs"
|
size="xs"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditing(true)
|
setEditing(true)
|
||||||
editor
|
editor?.commands.focus('end')
|
||||||
?.chain()
|
insertContent(editor, `<p>${editTimestamp()}</p>`)
|
||||||
.setContent(contract.description)
|
|
||||||
.focus('end')
|
|
||||||
.insertContent(`<p>${editTimestamp()}</p>`)
|
|
||||||
.run()
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Edit description
|
Edit description
|
||||||
|
@ -131,7 +128,7 @@ function EditQuestion(props: {
|
||||||
|
|
||||||
function joinContent(oldContent: ContentType, newContent: string) {
|
function joinContent(oldContent: ContentType, newContent: string) {
|
||||||
const editor = new Editor({ content: oldContent, extensions: exhibitExts })
|
const editor = new Editor({ content: oldContent, extensions: exhibitExts })
|
||||||
editor.chain().focus('end').insertContent(newContent).run()
|
insertContent(editor, newContent)
|
||||||
return editor.getJSON()
|
return editor.getJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -33,6 +33,8 @@ import { Col } from 'web/components/layout/col'
|
||||||
import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
|
import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import { groupPath } from 'web/lib/firebase/groups'
|
import { groupPath } from 'web/lib/firebase/groups'
|
||||||
|
import { insertContent } from '../editor/utils'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export type ShowTime = 'resolve-date' | 'close-date'
|
export type ShowTime = 'resolve-date' | 'close-date'
|
||||||
|
|
||||||
|
@ -56,13 +58,13 @@ export function MiscDetails(props: {
|
||||||
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
|
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="items-center gap-3 text-sm text-gray-400">
|
<Row className="items-center gap-3 truncate text-sm text-gray-400">
|
||||||
{showHotVolume ? (
|
{showHotVolume ? (
|
||||||
<Row className="gap-0.5">
|
<Row className="gap-0.5">
|
||||||
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
|
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
|
||||||
</Row>
|
</Row>
|
||||||
) : showTime === 'close-date' ? (
|
) : showTime === 'close-date' ? (
|
||||||
<Row className="gap-0.5">
|
<Row className="gap-0.5 whitespace-nowrap">
|
||||||
<ClockIcon className="h-5 w-5" />
|
<ClockIcon className="h-5 w-5" />
|
||||||
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
|
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
|
||||||
{fromNow(closeTime || 0)}
|
{fromNow(closeTime || 0)}
|
||||||
|
@ -82,30 +84,33 @@ export function MiscDetails(props: {
|
||||||
{!hideGroupLink && groupLinks && groupLinks.length > 0 && (
|
{!hideGroupLink && groupLinks && groupLinks.length > 0 && (
|
||||||
<SiteLink
|
<SiteLink
|
||||||
href={groupPath(groupLinks[0].slug)}
|
href={groupPath(groupLinks[0].slug)}
|
||||||
className="text-sm text-gray-400"
|
className="truncate text-sm text-gray-400"
|
||||||
>
|
>
|
||||||
<Row className={'line-clamp-1 flex-wrap items-center '}>
|
|
||||||
<UserGroupIcon className="mx-1 mb-0.5 inline h-4 w-4 shrink-0" />
|
|
||||||
{groupLinks[0].name}
|
{groupLinks[0].name}
|
||||||
</Row>
|
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AvatarDetails(props: { contract: Contract }) {
|
export function AvatarDetails(props: {
|
||||||
const { contract } = props
|
contract: Contract
|
||||||
|
className?: string
|
||||||
|
short?: boolean
|
||||||
|
}) {
|
||||||
|
const { contract, short, className } = props
|
||||||
const { creatorName, creatorUsername } = contract
|
const { creatorName, creatorUsername } = contract
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="items-center gap-2 text-sm text-gray-400">
|
<Row
|
||||||
|
className={clsx('items-center gap-2 text-sm text-gray-400', className)}
|
||||||
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
username={creatorUsername}
|
username={creatorUsername}
|
||||||
avatarUrl={contract.creatorAvatarUrl}
|
avatarUrl={contract.creatorAvatarUrl}
|
||||||
size={6}
|
size={6}
|
||||||
/>
|
/>
|
||||||
<UserLink name={creatorName} username={creatorUsername} />
|
<UserLink name={creatorName} username={creatorUsername} short={short} />
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -148,7 +153,7 @@ export function ContractDetails(props: {
|
||||||
const groupInfo = (
|
const groupInfo = (
|
||||||
<Row>
|
<Row>
|
||||||
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
|
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
|
||||||
<span className={'line-clamp-1'}>
|
<span className="truncate">
|
||||||
{groupToDisplay ? groupToDisplay.name : 'No group'}
|
{groupToDisplay ? groupToDisplay.name : 'No group'}
|
||||||
</span>
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -210,7 +215,7 @@ export function ContractDetails(props: {
|
||||||
<>
|
<>
|
||||||
<DateTimeTooltip
|
<DateTimeTooltip
|
||||||
text="Market resolved:"
|
text="Market resolved:"
|
||||||
time={contract.resolutionTime}
|
time={dayjs(contract.resolutionTime)}
|
||||||
>
|
>
|
||||||
{resolvedDate}
|
{resolvedDate}
|
||||||
</DateTimeTooltip>
|
</DateTimeTooltip>
|
||||||
|
@ -266,13 +271,16 @@ function EditableCloseDate(props: {
|
||||||
}) {
|
}) {
|
||||||
const { closeTime, contract, isCreator } = props
|
const { closeTime, contract, isCreator } = props
|
||||||
|
|
||||||
|
const dayJsCloseTime = dayjs(closeTime)
|
||||||
|
const dayJsNow = dayjs()
|
||||||
|
|
||||||
const [isEditingCloseTime, setIsEditingCloseTime] = useState(false)
|
const [isEditingCloseTime, setIsEditingCloseTime] = useState(false)
|
||||||
const [closeDate, setCloseDate] = useState(
|
const [closeDate, setCloseDate] = useState(
|
||||||
closeTime && dayjs(closeTime).format('YYYY-MM-DDTHH:mm')
|
closeTime && dayJsCloseTime.format('YYYY-MM-DDTHH:mm')
|
||||||
)
|
)
|
||||||
|
|
||||||
const isSameYear = dayjs(closeTime).isSame(dayjs(), 'year')
|
const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year')
|
||||||
const isSameDay = dayjs(closeTime).isSame(dayjs(), 'day')
|
const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day')
|
||||||
|
|
||||||
const onSave = () => {
|
const onSave = () => {
|
||||||
const newCloseTime = dayjs(closeDate).valueOf()
|
const newCloseTime = dayjs(closeDate).valueOf()
|
||||||
|
@ -282,12 +290,11 @@ function EditableCloseDate(props: {
|
||||||
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
|
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
|
||||||
|
|
||||||
const editor = new Editor({ content, extensions: exhibitExts })
|
const editor = new Editor({ content, extensions: exhibitExts })
|
||||||
editor
|
editor.commands.focus('end')
|
||||||
.chain()
|
insertContent(
|
||||||
.focus('end')
|
editor,
|
||||||
.insertContent('<br /><br />')
|
`<br><p>Close date updated to ${formattedCloseDate}</p>`
|
||||||
.insertContent(`Close date updated to ${formattedCloseDate}`)
|
)
|
||||||
.run()
|
|
||||||
|
|
||||||
updateContract(contract.id, {
|
updateContract(contract.id, {
|
||||||
closeTime: newCloseTime,
|
closeTime: newCloseTime,
|
||||||
|
@ -314,11 +321,11 @@ function EditableCloseDate(props: {
|
||||||
) : (
|
) : (
|
||||||
<DateTimeTooltip
|
<DateTimeTooltip
|
||||||
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
|
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
|
||||||
time={closeTime}
|
time={dayJsCloseTime}
|
||||||
>
|
>
|
||||||
{isSameYear
|
{isSameYear
|
||||||
? dayjs(closeTime).format('MMM D')
|
? dayJsCloseTime.format('MMM D')
|
||||||
: dayjs(closeTime).format('MMM D, YYYY')}
|
: dayJsCloseTime.format('MMM D, YYYY')}
|
||||||
{isSameDay && <> ({fromNow(closeTime)})</>}
|
{isSameDay && <> ({fromNow(closeTime)})</>}
|
||||||
</DateTimeTooltip>
|
</DateTimeTooltip>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -107,7 +107,6 @@ export function ContractTopTrades(props: {
|
||||||
comment={commentsById[topCommentId]}
|
comment={commentsById[topCommentId]}
|
||||||
tips={tips[topCommentId]}
|
tips={tips[topCommentId]}
|
||||||
betsBySameUser={[betsById[topCommentId]]}
|
betsBySameUser={[betsById[topCommentId]]}
|
||||||
truncate={false}
|
|
||||||
smallAvatar={false}
|
smallAvatar={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,18 +8,17 @@ import { Spacer } from '../layout/spacer'
|
||||||
import { Tabs } from '../layout/tabs'
|
import { Tabs } from '../layout/tabs'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
|
||||||
import { useComments } from 'web/hooks/use-comments'
|
import { useComments } from 'web/hooks/use-comments'
|
||||||
|
import { useLiquidity } from 'web/hooks/use-liquidity'
|
||||||
|
|
||||||
export function ContractTabs(props: {
|
export function ContractTabs(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
liquidityProvisions: LiquidityProvision[]
|
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
}) {
|
}) {
|
||||||
const { contract, user, bets, tips, liquidityProvisions } = props
|
const { contract, user, bets, tips } = props
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
|
|
||||||
const userBets = user && bets.filter((bet) => bet.userId === user.id)
|
const userBets = user && bets.filter((bet) => bet.userId === user.id)
|
||||||
|
@ -27,6 +26,9 @@ export function ContractTabs(props: {
|
||||||
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
|
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const liquidityProvisions =
|
||||||
|
useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? []
|
||||||
|
|
||||||
// Load comments here, so the badge count will be correct
|
// Load comments here, so the badge count will be correct
|
||||||
const updatedComments = useComments(contract.id)
|
const updatedComments = useComments(contract.id)
|
||||||
const comments = updatedComments ?? props.comments
|
const comments = updatedComments ?? props.comments
|
||||||
|
|
|
@ -5,9 +5,10 @@ import { SiteLink } from '../site-link'
|
||||||
import { ContractCard } from './contract-card'
|
import { ContractCard } from './contract-card'
|
||||||
import { ShowTime } from './contract-details'
|
import { ShowTime } from './contract-details'
|
||||||
import { ContractSearch } from '../contract-search'
|
import { ContractSearch } from '../contract-search'
|
||||||
import { useIsVisible } from 'web/hooks/use-is-visible'
|
import { useCallback } from 'react'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { LoadingIndicator } from '../loading-indicator'
|
||||||
|
import { VisibilityObserver } from '../visibility-observer'
|
||||||
|
|
||||||
export type ContractHighlightOptions = {
|
export type ContractHighlightOptions = {
|
||||||
contractIds?: string[]
|
contractIds?: string[]
|
||||||
|
@ -15,9 +16,8 @@ export type ContractHighlightOptions = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContractsGrid(props: {
|
export function ContractsGrid(props: {
|
||||||
contracts: Contract[]
|
contracts: Contract[] | undefined
|
||||||
loadMore: () => void
|
loadMore?: () => void
|
||||||
hasMore: boolean
|
|
||||||
showTime?: ShowTime
|
showTime?: ShowTime
|
||||||
onContractClick?: (contract: Contract) => void
|
onContractClick?: (contract: Contract) => void
|
||||||
overrideGridClassName?: string
|
overrideGridClassName?: string
|
||||||
|
@ -30,7 +30,6 @@ export function ContractsGrid(props: {
|
||||||
const {
|
const {
|
||||||
contracts,
|
contracts,
|
||||||
showTime,
|
showTime,
|
||||||
hasMore,
|
|
||||||
loadMore,
|
loadMore,
|
||||||
onContractClick,
|
onContractClick,
|
||||||
overrideGridClassName,
|
overrideGridClassName,
|
||||||
|
@ -38,16 +37,19 @@ export function ContractsGrid(props: {
|
||||||
highlightOptions,
|
highlightOptions,
|
||||||
} = props
|
} = props
|
||||||
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
|
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
|
||||||
|
|
||||||
const { contractIds, highlightClassName } = highlightOptions || {}
|
const { contractIds, highlightClassName } = highlightOptions || {}
|
||||||
const [elem, setElem] = useState<HTMLElement | null>(null)
|
const onVisibilityUpdated = useCallback(
|
||||||
const isBottomVisible = useIsVisible(elem)
|
(visible) => {
|
||||||
|
if (visible && loadMore) {
|
||||||
useEffect(() => {
|
|
||||||
if (isBottomVisible && hasMore) {
|
|
||||||
loadMore()
|
loadMore()
|
||||||
}
|
}
|
||||||
}, [isBottomVisible, hasMore, loadMore])
|
},
|
||||||
|
[loadMore]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (contracts === undefined) {
|
||||||
|
return <LoadingIndicator />
|
||||||
|
}
|
||||||
|
|
||||||
if (contracts.length === 0) {
|
if (contracts.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
@ -87,21 +89,25 @@ export function ContractsGrid(props: {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div ref={setElem} className="relative -top-96 h-1" />
|
<VisibilityObserver
|
||||||
|
onVisibilityUpdated={onVisibilityUpdated}
|
||||||
|
className="relative -top-96 h-1"
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreatorContractsList(props: { creator: User }) {
|
export function CreatorContractsList(props: {
|
||||||
const { creator } = props
|
user: User | null | undefined
|
||||||
|
creator: User
|
||||||
|
}) {
|
||||||
|
const { user, creator } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContractSearch
|
<ContractSearch
|
||||||
querySortOptions={{
|
user={user}
|
||||||
defaultSort: 'newest',
|
defaultSort="newest"
|
||||||
defaultFilter: 'all',
|
defaultFilter="all"
|
||||||
shouldLoadFromStorage: false,
|
|
||||||
}}
|
|
||||||
additionalFilter={{
|
additionalFilter={{
|
||||||
creatorId: creator.id,
|
creatorId: creator.id,
|
||||||
}}
|
}}
|
|
@ -138,7 +138,7 @@ export function QuickBet(props: {
|
||||||
return (
|
return (
|
||||||
<Col
|
<Col
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'relative -my-4 -mr-5 min-w-[5.5rem] justify-center gap-2 pr-5 pl-1 align-middle'
|
'relative min-w-[5.5rem] justify-center gap-2 pr-5 pl-1 align-middle'
|
||||||
// Use this for colored QuickBet panes
|
// Use this for colored QuickBet panes
|
||||||
// `bg-opacity-10 bg-${color}`
|
// `bg-opacity-10 bg-${color}`
|
||||||
)}
|
)}
|
||||||
|
@ -319,7 +319,7 @@ function getProb(contract: Contract) {
|
||||||
? getBinaryProb(contract)
|
? getBinaryProb(contract)
|
||||||
: outcomeType === 'PSEUDO_NUMERIC'
|
: outcomeType === 'PSEUDO_NUMERIC'
|
||||||
? getProbability(contract)
|
? getProbability(contract)
|
||||||
: outcomeType === 'FREE_RESPONSE'
|
: outcomeType === 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE'
|
||||||
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
|
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
|
||||||
: outcomeType === 'NUMERIC'
|
: outcomeType === 'NUMERIC'
|
||||||
? getNumericScale(contract)
|
? getNumericScale(contract)
|
||||||
|
|
|
@ -14,7 +14,9 @@ import { Button } from '../button'
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { User } from 'common/user'
|
import { REFERRAL_AMOUNT, User } from 'common/user'
|
||||||
|
import { SiteLink } from '../site-link'
|
||||||
|
import { formatMoney } from 'common/util/format'
|
||||||
|
|
||||||
export function ShareModal(props: {
|
export function ShareModal(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -26,38 +28,45 @@ export function ShareModal(props: {
|
||||||
|
|
||||||
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
|
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
|
||||||
|
|
||||||
const copyPayload = `https://${ENV_CONFIG.domain}${contractPath(contract)}${
|
const shareUrl = `https://${ENV_CONFIG.domain}${contractPath(contract)}${
|
||||||
user?.username && contract.creatorUsername !== user?.username
|
user?.username && contract.creatorUsername !== user?.username
|
||||||
? '?referrer=' + user?.username
|
? '?referrer=' + user?.username
|
||||||
: ''
|
: ''
|
||||||
}`
|
}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} setOpen={setOpen}>
|
<Modal open={isOpen} setOpen={setOpen} size="md">
|
||||||
<Col className="gap-4 rounded bg-white p-4">
|
<Col className="gap-4 rounded bg-white p-4">
|
||||||
<Title className="!mt-0 mb-2" text="Share this market" />
|
<Title className="!mt-0 !mb-2" text="Share this market" />
|
||||||
|
<p>
|
||||||
|
Earn{' '}
|
||||||
|
<SiteLink href="/referrals">
|
||||||
|
{formatMoney(REFERRAL_AMOUNT)} referral bonus
|
||||||
|
</SiteLink>{' '}
|
||||||
|
if a new user signs up using the link!
|
||||||
|
</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="2xl"
|
size="2xl"
|
||||||
color="gradient"
|
color="gradient"
|
||||||
className={'mb-2 flex max-w-xs self-center'}
|
className={'mb-2 flex max-w-xs self-center'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
copyToClipboard(copyPayload)
|
copyToClipboard(shareUrl)
|
||||||
track('copy share link')
|
|
||||||
toast.success('Link copied!', {
|
toast.success('Link copied!', {
|
||||||
icon: linkIcon,
|
icon: linkIcon,
|
||||||
})
|
})
|
||||||
|
track('copy share link')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{linkIcon} Copy link
|
{linkIcon} Copy link
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Row className="justify-start gap-4 self-center">
|
<Row className="z-0 justify-start gap-4 self-center">
|
||||||
<TweetButton
|
<TweetButton
|
||||||
className="self-start"
|
className="self-start"
|
||||||
tweetText={getTweetText(contract)}
|
tweetText={getTweetText(contract, shareUrl)}
|
||||||
/>
|
/>
|
||||||
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
|
<ShareEmbedButton contract={contract} />
|
||||||
<DuplicateContractButton contract={contract} />
|
<DuplicateContractButton contract={contract} />
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -65,13 +74,9 @@ export function ShareModal(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTweetText = (contract: Contract) => {
|
const getTweetText = (contract: Contract, url: string) => {
|
||||||
const { question, resolution } = contract
|
const { question, resolution } = contract
|
||||||
|
|
||||||
const tweetDescription = resolution ? `\n\nResolved ${resolution}!` : ''
|
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}`
|
return `${question}\n\n${url}${tweetDescription}`
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { CreateChallengeModal } from '../challenges/create-challenge-modal'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||||
import { ShareModal } from './share-modal'
|
import { ShareModal } from './share-modal'
|
||||||
|
import { withTracking } from 'web/lib/service/analytics'
|
||||||
|
|
||||||
export function ShareRow(props: {
|
export function ShareRow(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -44,7 +45,14 @@ export function ShareRow(props: {
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showChallenge && (
|
{showChallenge && (
|
||||||
<Button size="lg" color="gray-white" onClick={() => setIsOpen(true)}>
|
<Button
|
||||||
|
size="lg"
|
||||||
|
color="gray-white"
|
||||||
|
onClick={withTracking(
|
||||||
|
() => setIsOpen(true),
|
||||||
|
'click challenge button'
|
||||||
|
)}
|
||||||
|
>
|
||||||
⚔️ Challenge
|
⚔️ Challenge
|
||||||
<CreateChallengeModal
|
<CreateChallengeModal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
|
|
@ -1,35 +1,28 @@
|
||||||
import React from 'react'
|
import dayjs, { Dayjs } from 'dayjs'
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import utc from 'dayjs/plugin/utc'
|
import utc from 'dayjs/plugin/utc'
|
||||||
import timezone from 'dayjs/plugin/timezone'
|
import timezone from 'dayjs/plugin/timezone'
|
||||||
import advanced from 'dayjs/plugin/advancedFormat'
|
import advanced from 'dayjs/plugin/advancedFormat'
|
||||||
import { ClientRender } from './client-render'
|
import { Tooltip } from './tooltip'
|
||||||
|
|
||||||
dayjs.extend(utc)
|
dayjs.extend(utc)
|
||||||
dayjs.extend(timezone)
|
dayjs.extend(timezone)
|
||||||
dayjs.extend(advanced)
|
dayjs.extend(advanced)
|
||||||
|
|
||||||
export function DateTimeTooltip(props: {
|
export function DateTimeTooltip(props: {
|
||||||
time: number
|
time: Dayjs
|
||||||
text?: string
|
text?: string
|
||||||
|
className?: string
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
noTap?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { time, text } = props
|
const { className, time, text, noTap } = props
|
||||||
|
|
||||||
const formattedTime = dayjs(time).format('MMM DD, YYYY hh:mm a z')
|
const formattedTime = time.format('MMM DD, YYYY hh:mm a z')
|
||||||
const toolTip = text ? `${text} ${formattedTime}` : formattedTime
|
const toolTip = text ? `${text} ${formattedTime}` : formattedTime
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Tooltip className={className} text={toolTip} noTap={noTap}>
|
||||||
<ClientRender>
|
|
||||||
<span
|
|
||||||
className="tooltip hidden cursor-default sm:inline-block"
|
|
||||||
data-tip={toolTip}
|
|
||||||
>
|
|
||||||
{props.children}
|
{props.children}
|
||||||
</span>
|
</Tooltip>
|
||||||
</ClientRender>
|
|
||||||
<span className="whitespace-nowrap sm:hidden">{props.children}</span>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@ import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import {
|
import {
|
||||||
useEditor,
|
useEditor,
|
||||||
EditorContent,
|
EditorContent,
|
||||||
FloatingMenu,
|
|
||||||
JSONContent,
|
JSONContent,
|
||||||
Content,
|
Content,
|
||||||
Editor,
|
Editor,
|
||||||
|
@ -11,28 +10,42 @@ import {
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import { Image } from '@tiptap/extension-image'
|
import { Image } from '@tiptap/extension-image'
|
||||||
import { Link } from '@tiptap/extension-link'
|
import { Link } from '@tiptap/extension-link'
|
||||||
import { Mention } from '@tiptap/extension-mention'
|
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Linkify } from './linkify'
|
import { Linkify } from './linkify'
|
||||||
import { uploadImage } from 'web/lib/firebase/storage'
|
import { uploadImage } from 'web/lib/firebase/storage'
|
||||||
import { useMutation } from 'react-query'
|
import { useMutation } from 'react-query'
|
||||||
import { exhibitExts } from 'common/util/parse'
|
|
||||||
import { FileUploadButton } from './file-upload-button'
|
import { FileUploadButton } from './file-upload-button'
|
||||||
import { linkClass } from './site-link'
|
import { linkClass } from './site-link'
|
||||||
import { useUsers } from 'web/hooks/use-users'
|
import { useUsers } from 'web/hooks/use-users'
|
||||||
import { mentionSuggestion } from './editor/mention-suggestion'
|
import { mentionSuggestion } from './editor/mention-suggestion'
|
||||||
import { DisplayMention } from './editor/mention'
|
import { DisplayMention } from './editor/mention'
|
||||||
import Iframe from 'common/util/tiptap-iframe'
|
import Iframe from 'common/util/tiptap-iframe'
|
||||||
import { CodeIcon, PhotographIcon } from '@heroicons/react/solid'
|
import TiptapTweet from './editor/tiptap-tweet'
|
||||||
import { Modal } from './layout/modal'
|
import { EmbedModal } from './editor/embed-modal'
|
||||||
import { Col } from './layout/col'
|
import {
|
||||||
import { Button } from './button'
|
CodeIcon,
|
||||||
import { Row } from './layout/row'
|
PhotographIcon,
|
||||||
import { Spacer } from './layout/spacer'
|
PresentationChartLineIcon,
|
||||||
|
} from '@heroicons/react/solid'
|
||||||
|
import { MarketModal } from './editor/market-modal'
|
||||||
|
import { insertContent } from './editor/utils'
|
||||||
|
import { Tooltip } from './tooltip'
|
||||||
|
|
||||||
|
const DisplayImage = Image.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'max-h-60',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DisplayLink = Link.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: clsx('no-underline !text-indigo-700', linkClass),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const proseClass = clsx(
|
const proseClass = clsx(
|
||||||
'prose prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
|
'prose prose-p:my-0 prose-ul:my-0 prose-ol:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
|
||||||
'font-light prose-a:font-light prose-blockquote:font-light'
|
'font-light prose-a:font-light prose-blockquote:font-light'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -41,14 +54,16 @@ export function useTextEditor(props: {
|
||||||
max?: number
|
max?: number
|
||||||
defaultValue?: Content
|
defaultValue?: Content
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
simple?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { placeholder, max, defaultValue = '', disabled } = props
|
const { placeholder, max, defaultValue = '', disabled, simple } = props
|
||||||
|
|
||||||
const users = useUsers()
|
const users = useUsers()
|
||||||
|
|
||||||
const editorClass = clsx(
|
const editorClass = clsx(
|
||||||
proseClass,
|
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(
|
const editor = useEditor(
|
||||||
|
@ -56,24 +71,22 @@ export function useTextEditor(props: {
|
||||||
editorProps: { attributes: { class: editorClass } },
|
editorProps: { attributes: { class: editorClass } },
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: { levels: [1, 2, 3] },
|
heading: simple ? false : { levels: [1, 2, 3] },
|
||||||
|
horizontalRule: simple ? false : {},
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder,
|
placeholder,
|
||||||
emptyEditorClass:
|
emptyEditorClass:
|
||||||
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0',
|
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
|
||||||
}),
|
}),
|
||||||
CharacterCount.configure({ limit: max }),
|
CharacterCount.configure({ limit: max }),
|
||||||
Image,
|
simple ? DisplayImage : Image,
|
||||||
Link.configure({
|
DisplayLink,
|
||||||
HTMLAttributes: {
|
|
||||||
class: clsx('no-underline !text-indigo-700', linkClass),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
DisplayMention.configure({
|
DisplayMention.configure({
|
||||||
suggestion: mentionSuggestion(users),
|
suggestion: mentionSuggestion(users),
|
||||||
}),
|
}),
|
||||||
Iframe,
|
Iframe,
|
||||||
|
TiptapTweet,
|
||||||
],
|
],
|
||||||
content: defaultValue,
|
content: defaultValue,
|
||||||
},
|
},
|
||||||
|
@ -97,7 +110,7 @@ export function useTextEditor(props: {
|
||||||
// If the pasted content is iframe code, directly inject it
|
// If the pasted content is iframe code, directly inject it
|
||||||
const text = event.clipboardData?.getData('text/plain').trim() ?? ''
|
const text = event.clipboardData?.getData('text/plain').trim() ?? ''
|
||||||
if (isValidIframe(text)) {
|
if (isValidIframe(text)) {
|
||||||
editor.chain().insertContent(text).run()
|
insertContent(editor, text)
|
||||||
return true // Prevent the code from getting pasted as text
|
return true // Prevent the code from getting pasted as text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,67 +133,68 @@ function isValidIframe(text: string) {
|
||||||
export function TextEditor(props: {
|
export function TextEditor(props: {
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
upload: ReturnType<typeof useUploadMutation>
|
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 [iframeOpen, setIframeOpen] = useState(false)
|
||||||
|
const [marketOpen, setMarketOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* hide placeholder when focused */}
|
{/* hide placeholder when focused */}
|
||||||
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
|
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
|
||||||
{editor && (
|
<div className="rounded-lg border border-gray-300 bg-white shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
|
||||||
<FloatingMenu
|
|
||||||
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!
|
|
||||||
</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">
|
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
{/* Spacer element to match the height of the toolbar */}
|
{/* Toolbar, with buttons for images and embeds */}
|
||||||
<div className="py-2" aria-hidden="true">
|
<div className="flex h-9 items-center gap-5 pl-4 pr-1">
|
||||||
{/* Matches height of button in toolbar (1px border + 36px content height) */}
|
<Tooltip className="flex items-center" text="Add image" noTap>
|
||||||
<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">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FileUploadButton
|
<FileUploadButton
|
||||||
onFiles={upload.mutate}
|
onFiles={upload.mutate}
|
||||||
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
|
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
|
||||||
>
|
>
|
||||||
<PhotographIcon className="h-5 w-5" aria-hidden="true" />
|
<PhotographIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
<span className="sr-only">Upload an image</span>
|
|
||||||
</FileUploadButton>
|
</FileUploadButton>
|
||||||
</div>
|
</Tooltip>
|
||||||
<div className="flex items-center">
|
<Tooltip className="flex items-center" text="Add embed" noTap>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIframeOpen(true)}
|
onClick={() => setIframeOpen(true)}
|
||||||
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
|
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
|
||||||
>
|
>
|
||||||
<IframeModal
|
<EmbedModal
|
||||||
editor={editor}
|
editor={editor}
|
||||||
open={iframeOpen}
|
open={iframeOpen}
|
||||||
setOpen={setIframeOpen}
|
setOpen={setIframeOpen}
|
||||||
/>
|
/>
|
||||||
<CodeIcon className="h-5 w-5" aria-hidden="true" />
|
<CodeIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
<span className="sr-only">Embed an iframe</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</Tooltip>
|
||||||
|
<Tooltip className="flex items-center" text="Add market" noTap>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMarketOpen(true)}
|
||||||
|
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
|
||||||
|
>
|
||||||
|
<MarketModal
|
||||||
|
editor={editor}
|
||||||
|
open={marketOpen}
|
||||||
|
setOpen={setMarketOpen}
|
||||||
|
/>
|
||||||
|
<PresentationChartLineIcon
|
||||||
|
className="h-5 w-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
{/* Spacer that also focuses editor on click */}
|
||||||
|
<div
|
||||||
|
className="grow cursor-text self-stretch"
|
||||||
|
onMouseDown={() =>
|
||||||
|
editor?.chain().focus('end').createParagraphNear().run()
|
||||||
|
}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -192,65 +206,6 @@ export function TextEditor(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function IframeModal(props: {
|
|
||||||
editor: Editor | null
|
|
||||||
open: boolean
|
|
||||||
setOpen: (open: boolean) => void
|
|
||||||
}) {
|
|
||||||
const { editor, open, setOpen } = props
|
|
||||||
const [embedCode, setEmbedCode] = useState('')
|
|
||||||
const valid = isValidIframe(embedCode)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open={open} setOpen={setOpen}>
|
|
||||||
<Col className="gap-2 rounded bg-white p-6">
|
|
||||||
<label
|
|
||||||
htmlFor="embed"
|
|
||||||
className="block text-sm font-medium text-gray-700"
|
|
||||||
>
|
|
||||||
Embed a market, Youtube video, etc.
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="embed"
|
|
||||||
id="embed"
|
|
||||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
|
||||||
placeholder='e.g. <iframe src="..."></iframe>'
|
|
||||||
value={embedCode}
|
|
||||||
onChange={(e) => setEmbedCode(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Preview the embed if it's valid */}
|
|
||||||
{valid ? <RichContent content={embedCode} /> : <Spacer h={2} />}
|
|
||||||
|
|
||||||
<Row className="gap-2">
|
|
||||||
<Button
|
|
||||||
disabled={!valid}
|
|
||||||
onClick={() => {
|
|
||||||
if (editor && valid) {
|
|
||||||
editor.chain().insertContent(embedCode).run()
|
|
||||||
setEmbedCode('')
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Embed
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="gray"
|
|
||||||
onClick={() => {
|
|
||||||
setEmbedCode('')
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Row>
|
|
||||||
</Col>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const useUploadMutation = (editor: Editor | null) =>
|
const useUploadMutation = (editor: Editor | null) =>
|
||||||
useMutation(
|
useMutation(
|
||||||
(files: File[]) =>
|
(files: File[]) =>
|
||||||
|
@ -269,14 +224,20 @@ const useUploadMutation = (editor: Editor | null) =>
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function RichContent(props: { content: JSONContent | string }) {
|
export function RichContent(props: {
|
||||||
const { content } = props
|
content: JSONContent | string
|
||||||
|
smallImage?: boolean
|
||||||
|
}) {
|
||||||
|
const { content, smallImage } = props
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
editorProps: { attributes: { class: proseClass } },
|
editorProps: { attributes: { class: proseClass } },
|
||||||
extensions: [
|
extensions: [
|
||||||
// replace tiptap's Mention with ours, to add style and link
|
StarterKit,
|
||||||
...exhibitExts.filter((ex) => ex.name !== Mention.name),
|
smallImage ? DisplayImage : Image,
|
||||||
|
DisplayLink,
|
||||||
DisplayMention,
|
DisplayMention,
|
||||||
|
Iframe,
|
||||||
|
TiptapTweet,
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
editable: false,
|
editable: false,
|
||||||
|
@ -287,13 +248,16 @@ function RichContent(props: { content: JSONContent | string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// backwards compatibility: we used to store content as strings
|
// backwards compatibility: we used to store content as strings
|
||||||
export function Content(props: { content: JSONContent | string }) {
|
export function Content(props: {
|
||||||
|
content: JSONContent | string
|
||||||
|
smallImage?: boolean
|
||||||
|
}) {
|
||||||
const { content } = props
|
const { content } = props
|
||||||
return typeof content === 'string' ? (
|
return typeof content === 'string' ? (
|
||||||
<div className="whitespace-pre-line font-light leading-relaxed">
|
<div className="whitespace-pre-line font-light leading-relaxed">
|
||||||
<Linkify text={content} />
|
<Linkify text={content} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<RichContent content={content} />
|
<RichContent {...props} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
130
web/components/editor/embed-modal.tsx
Normal file
130
web/components/editor/embed-modal.tsx
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
import { Editor } from '@tiptap/react'
|
||||||
|
import { DOMAIN } from 'common/envs/constants'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '../button'
|
||||||
|
import { RichContent } from '../editor'
|
||||||
|
import { Col } from '../layout/col'
|
||||||
|
import { Modal } from '../layout/modal'
|
||||||
|
import { Row } from '../layout/row'
|
||||||
|
import { Spacer } from '../layout/spacer'
|
||||||
|
|
||||||
|
type EmbedPattern = {
|
||||||
|
// Regex should have a single capture group.
|
||||||
|
regex: RegExp
|
||||||
|
rewrite: (text: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const embedPatterns: EmbedPattern[] = [
|
||||||
|
{
|
||||||
|
regex: /^(<iframe.*<\/iframe>)$/,
|
||||||
|
rewrite: (text: string) => text,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /^https?:\/\/manifold\.markets\/([^\/]+\/[^\/]+)/,
|
||||||
|
rewrite: (slug) =>
|
||||||
|
`<iframe src="https://manifold.markets/embed/${slug}"></iframe>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /^https?:\/\/twitter\.com\/.*\/status\/(\d+)/,
|
||||||
|
// Hack: append a leading 't', to prevent tweetId from being interpreted as a number.
|
||||||
|
// If it's a number, there may be numeric precision issues.
|
||||||
|
rewrite: (id) => `<tiptap-tweet tweetid="t${id}"></tiptap-tweet>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /^https?:\/\/www\.youtube\.com\/watch\?v=([^&]+)/,
|
||||||
|
rewrite: (id) =>
|
||||||
|
`<iframe src="https://www.youtube.com/embed/${id}"></iframe>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /^https?:\/\/www\.metaculus\.com\/questions\/(\d+)/,
|
||||||
|
rewrite: (id) =>
|
||||||
|
`<iframe src="https://www.metaculus.com/questions/embed/${id}"></iframe>`,
|
||||||
|
},
|
||||||
|
// Twitch is a bit annoying, since it requires the `&parent=DOMAIN` to match
|
||||||
|
{
|
||||||
|
// Twitch: https://www.twitch.tv/videos/1445087149
|
||||||
|
regex: /^https?:\/\/www\.twitch\.tv\/videos\/(\d+)/,
|
||||||
|
rewrite: (id) =>
|
||||||
|
`<iframe src="https://player.twitch.tv/?video=${id}&parent=${DOMAIN}"></iframe>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Twitch: https://www.twitch.tv/sirsalty
|
||||||
|
regex: /^https?:\/\/www\.twitch\.tv\/([^\/]+)/,
|
||||||
|
rewrite: (channel) =>
|
||||||
|
`<iframe src="https://player.twitch.tv/?channel=${channel}&parent=${DOMAIN}"></iframe>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /^(https?:\/\/.*)/,
|
||||||
|
rewrite: (url) => `<iframe src="${url}"></iframe>`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function embedCode(text: string) {
|
||||||
|
for (const pattern of embedPatterns) {
|
||||||
|
const match = text.match(pattern.regex)
|
||||||
|
if (match) {
|
||||||
|
return pattern.rewrite(match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmbedModal(props: {
|
||||||
|
editor: Editor | null
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { editor, open, setOpen } = props
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const embed = embedCode(input)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} setOpen={setOpen}>
|
||||||
|
<Col className="gap-2 rounded bg-white p-6">
|
||||||
|
<label
|
||||||
|
htmlFor="embed"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Embed a Youtube video, Tweet, or other link
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="embed"
|
||||||
|
id="embed"
|
||||||
|
className="block w-full rounded-md border-gray-300 shadow-sm placeholder:text-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="e.g. https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Preview the embed if it's valid */}
|
||||||
|
{embed ? <RichContent content={embed} /> : <Spacer h={2} />}
|
||||||
|
|
||||||
|
<Row className="gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={!embed}
|
||||||
|
onClick={() => {
|
||||||
|
if (editor && embed) {
|
||||||
|
editor.chain().insertContent(embed).run()
|
||||||
|
console.log('editorjson', editor.getJSON())
|
||||||
|
setInput('')
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Embed
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="gray"
|
||||||
|
onClick={() => {
|
||||||
|
setInput('')
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
84
web/components/editor/market-modal.tsx
Normal file
84
web/components/editor/market-modal.tsx
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import { Editor } from '@tiptap/react'
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '../button'
|
||||||
|
import { ContractSearch } from '../contract-search'
|
||||||
|
import { Col } from '../layout/col'
|
||||||
|
import { Modal } from '../layout/modal'
|
||||||
|
import { Row } from '../layout/row'
|
||||||
|
import { LoadingIndicator } from '../loading-indicator'
|
||||||
|
import { embedCode } from '../share-embed-button'
|
||||||
|
import { insertContent } from './utils'
|
||||||
|
|
||||||
|
export function MarketModal(props: {
|
||||||
|
editor: Editor | null
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { editor, open, setOpen } = props
|
||||||
|
|
||||||
|
const [contracts, setContracts] = useState<Contract[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function addContract(contract: Contract) {
|
||||||
|
if (contracts.map((c) => c.id).includes(contract.id)) {
|
||||||
|
setContracts(contracts.filter((c) => c.id !== contract.id))
|
||||||
|
} else setContracts([...contracts, contract])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doneAddingContracts() {
|
||||||
|
setLoading(true)
|
||||||
|
insertContent(editor, ...contracts.map(embedCode))
|
||||||
|
setLoading(false)
|
||||||
|
setOpen(false)
|
||||||
|
setContracts([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}>
|
||||||
|
<Col className="h-[85vh] w-full gap-4 rounded-md bg-white">
|
||||||
|
<Row className="p-8 pb-0">
|
||||||
|
<div className={'text-xl text-indigo-700'}>Embed a market</div>
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<Row className="grow justify-end gap-4">
|
||||||
|
{contracts.length > 0 && (
|
||||||
|
<Button onClick={doneAddingContracts} color={'indigo'}>
|
||||||
|
Embed {contracts.length} question
|
||||||
|
{contracts.length > 1 && 's'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => setContracts([])} color="gray">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="w-full justify-center">
|
||||||
|
<LoadingIndicator />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-y-scroll sm:px-8">
|
||||||
|
<ContractSearch
|
||||||
|
hideOrderSelector
|
||||||
|
onContractClick={addContract}
|
||||||
|
overrideGridClassName={
|
||||||
|
'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1'
|
||||||
|
}
|
||||||
|
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
||||||
|
highlightOptions={{
|
||||||
|
contractIds: contracts.map((c) => c.id),
|
||||||
|
highlightClassName:
|
||||||
|
'!bg-indigo-100 outline outline-2 outline-indigo-300',
|
||||||
|
}}
|
||||||
|
additionalFilter={{}} /* hide pills */
|
||||||
|
headerClassName="bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
|
@ -11,7 +11,7 @@ const name = 'mention-component'
|
||||||
|
|
||||||
const MentionComponent = (props: any) => {
|
const MentionComponent = (props: any) => {
|
||||||
return (
|
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} />
|
<Linkify text={'@' + props.node.attrs.label} />
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
)
|
)
|
||||||
|
@ -25,5 +25,6 @@ const MentionComponent = (props: any) => {
|
||||||
export const DisplayMention = Mention.extend({
|
export const DisplayMention = Mention.extend({
|
||||||
parseHTML: () => [{ tag: name }],
|
parseHTML: () => [{ tag: name }],
|
||||||
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
||||||
addNodeView: () => ReactNodeViewRenderer(MentionComponent),
|
addNodeView: () =>
|
||||||
|
ReactNodeViewRenderer(MentionComponent, { className: 'inline-block' }),
|
||||||
})
|
})
|
||||||
|
|
13
web/components/editor/tiptap-tweet.tsx
Normal file
13
web/components/editor/tiptap-tweet.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { Node } from '@tiptap/core'
|
||||||
|
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||||
|
import { TiptapTweetNode } from 'common/util/tiptap-tweet-type'
|
||||||
|
import WrappedTwitterTweetEmbed from './tweet-embed'
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
export default Node.create<TweetOptions>({
|
||||||
|
...TiptapTweetNode,
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(WrappedTwitterTweetEmbed)
|
||||||
|
},
|
||||||
|
})
|
19
web/components/editor/tweet-embed.tsx
Normal file
19
web/components/editor/tweet-embed.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { NodeViewWrapper } from '@tiptap/react'
|
||||||
|
import { TwitterTweetEmbed } from 'react-twitter-embed'
|
||||||
|
|
||||||
|
export default function WrappedTwitterTweetEmbed(props: {
|
||||||
|
node: {
|
||||||
|
attrs: {
|
||||||
|
tweetId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}): JSX.Element {
|
||||||
|
// Remove the leading 't' from the tweet id
|
||||||
|
const tweetId = props.node.attrs.tweetId.slice(1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className="tiptap-tweet">
|
||||||
|
<TwitterTweetEmbed tweetId={tweetId} />
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
13
web/components/editor/utils.ts
Normal file
13
web/components/editor/utils.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { Editor, Content } from '@tiptap/react'
|
||||||
|
|
||||||
|
export function insertContent(editor: Editor | null, ...contents: Content[]) {
|
||||||
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let e = editor.chain()
|
||||||
|
for (const content of contents) {
|
||||||
|
e = e.createParagraphNear().insertContent(content)
|
||||||
|
}
|
||||||
|
e.run()
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import { fromNow } from 'web/lib/util/time'
|
||||||
import { ToastClipboard } from 'web/components/toast-clipboard'
|
import { ToastClipboard } from 'web/components/toast-clipboard'
|
||||||
import { LinkIcon } from '@heroicons/react/outline'
|
import { LinkIcon } from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
export function CopyLinkDateTimeComponent(props: {
|
export function CopyLinkDateTimeComponent(props: {
|
||||||
prefix: string
|
prefix: string
|
||||||
|
@ -17,6 +18,7 @@ export function CopyLinkDateTimeComponent(props: {
|
||||||
}) {
|
}) {
|
||||||
const { prefix, slug, elementId, createdTime, className } = props
|
const { prefix, slug, elementId, createdTime, className } = props
|
||||||
const [showToast, setShowToast] = useState(false)
|
const [showToast, setShowToast] = useState(false)
|
||||||
|
const time = dayjs(createdTime)
|
||||||
|
|
||||||
function copyLinkToComment(
|
function copyLinkToComment(
|
||||||
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
||||||
|
@ -30,7 +32,7 @@ export function CopyLinkDateTimeComponent(props: {
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={clsx('inline', className)}>
|
<div className={clsx('inline', className)}>
|
||||||
<DateTimeTooltip time={createdTime}>
|
<DateTimeTooltip time={time} noTap>
|
||||||
<Link href={`/${prefix}/${slug}#${elementId}`} passHref={true}>
|
<Link href={`/${prefix}/${slug}#${elementId}`} passHref={true}>
|
||||||
<a
|
<a
|
||||||
onClick={(event) => copyLinkToComment(event)}
|
onClick={(event) => copyLinkToComment(event)}
|
||||||
|
|
|
@ -31,9 +31,9 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
const { answer, contract, comments, tips, bets, user } = props
|
const { answer, contract, comments, tips, bets, user } = props
|
||||||
const { username, avatarUrl, name, text } = answer
|
const { username, avatarUrl, name, text } = answer
|
||||||
|
|
||||||
const [replyToUsername, setReplyToUsername] = useState('')
|
const [replyToUser, setReplyToUser] =
|
||||||
|
useState<Pick<User, 'id' | 'username'>>()
|
||||||
const [showReply, setShowReply] = useState(false)
|
const [showReply, setShowReply] = useState(false)
|
||||||
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
|
|
||||||
const [highlighted, setHighlighted] = useState(false)
|
const [highlighted, setHighlighted] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
@ -70,9 +70,14 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
|
|
||||||
const scrollAndOpenReplyInput = useEvent(
|
const scrollAndOpenReplyInput = useEvent(
|
||||||
(comment?: Comment, answer?: Answer) => {
|
(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)
|
setShowReply(true)
|
||||||
inputRef?.focus()
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -80,7 +85,7 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
// Only show one comment input for a bet at a time
|
// Only show one comment input for a bet at a time
|
||||||
if (
|
if (
|
||||||
betsByCurrentUser.length > 1 &&
|
betsByCurrentUser.length > 1 &&
|
||||||
inputRef?.textContent?.length === 0 &&
|
// inputRef?.textContent?.length === 0 && //TODO: editor.isEmpty
|
||||||
betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0]
|
betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0]
|
||||||
?.outcome !== answer.number.toString()
|
?.outcome !== answer.number.toString()
|
||||||
)
|
)
|
||||||
|
@ -89,10 +94,6 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [betsByCurrentUser.length, user, answer.number])
|
}, [betsByCurrentUser.length, user, answer.number])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (showReply && inputRef) inputRef.focus()
|
|
||||||
}, [inputRef, showReply])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (router.asPath.endsWith(`#${answerElementId}`)) {
|
if (router.asPath.endsWith(`#${answerElementId}`)) {
|
||||||
setHighlighted(true)
|
setHighlighted(true)
|
||||||
|
@ -154,7 +155,6 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
commentsList={commentsList}
|
commentsList={commentsList}
|
||||||
betsByUserId={betsByUserId}
|
betsByUserId={betsByUserId}
|
||||||
smallAvatar={true}
|
smallAvatar={true}
|
||||||
truncate={false}
|
|
||||||
bets={bets}
|
bets={bets}
|
||||||
tips={tips}
|
tips={tips}
|
||||||
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
|
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
|
||||||
|
@ -172,12 +172,8 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
betsByCurrentUser={betsByCurrentUser}
|
betsByCurrentUser={betsByCurrentUser}
|
||||||
commentsByCurrentUser={commentsByCurrentUser}
|
commentsByCurrentUser={commentsByCurrentUser}
|
||||||
parentAnswerOutcome={answer.number.toString()}
|
parentAnswerOutcome={answer.number.toString()}
|
||||||
replyToUsername={replyToUsername}
|
replyToUser={replyToUser}
|
||||||
setRef={setInputRef}
|
onSubmitComment={() => setShowReply(false)}
|
||||||
onSubmitComment={() => {
|
|
||||||
setShowReply(false)
|
|
||||||
setReplyToUsername('')
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -36,8 +36,7 @@ export function FeedBet(props: {
|
||||||
const isSelf = user?.id === userId
|
const isSelf = user?.id === userId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Row className={'flex w-full items-center gap-2 pt-3'}>
|
||||||
<Row className={'flex w-full gap-2 pt-3'}>
|
|
||||||
{isSelf ? (
|
{isSelf ? (
|
||||||
<Avatar
|
<Avatar
|
||||||
className={clsx(smallAvatar && 'ml-1')}
|
className={clsx(smallAvatar && 'ml-1')}
|
||||||
|
@ -53,21 +52,17 @@ export function FeedBet(props: {
|
||||||
username={bettor.username}
|
username={bettor.username}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative px-1">
|
<EmptyAvatar className="mx-1" />
|
||||||
<EmptyAvatar />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className={'min-w-0 flex-1 py-1.5'}>
|
|
||||||
<BetStatusText
|
<BetStatusText
|
||||||
bet={bet}
|
bet={bet}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
isSelf={isSelf}
|
isSelf={isSelf}
|
||||||
bettor={bettor}
|
bettor={bettor}
|
||||||
hideOutcome={hideOutcome}
|
hideOutcome={hideOutcome}
|
||||||
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Row>
|
</Row>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,8 +72,9 @@ export function BetStatusText(props: {
|
||||||
isSelf: boolean
|
isSelf: boolean
|
||||||
bettor?: User
|
bettor?: User
|
||||||
hideOutcome?: boolean
|
hideOutcome?: boolean
|
||||||
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { bet, contract, bettor, isSelf, hideOutcome } = props
|
const { bet, contract, bettor, isSelf, hideOutcome, className } = props
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
const isFreeResponse = outcomeType === 'FREE_RESPONSE'
|
const isFreeResponse = outcomeType === 'FREE_RESPONSE'
|
||||||
|
@ -123,7 +119,7 @@ export function BetStatusText(props: {
|
||||||
: formatPercent(bet.limitProb ?? bet.probAfter)
|
: formatPercent(bet.limitProb ?? bet.probAfter)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-gray-500">
|
<div className={clsx('text-sm text-gray-500', className)}>
|
||||||
{bettor ? (
|
{bettor ? (
|
||||||
<UserLink name={bettor.name} username={bettor.username} />
|
<UserLink name={bettor.name} username={bettor.username} />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -13,25 +13,22 @@ import { Avatar } from 'web/components/avatar'
|
||||||
import { UserLink } from 'web/components/user-page'
|
import { UserLink } from 'web/components/user-page'
|
||||||
import { OutcomeLabel } from 'web/components/outcome-label'
|
import { OutcomeLabel } from 'web/components/outcome-label'
|
||||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
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 { firebaseLogin } from 'web/lib/firebase/users'
|
||||||
import {
|
import {
|
||||||
createCommentOnContract,
|
createCommentOnContract,
|
||||||
MAX_COMMENT_LENGTH,
|
MAX_COMMENT_LENGTH,
|
||||||
} from 'web/lib/firebase/comments'
|
} 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 { BetStatusText } from 'web/components/feed/feed-bets'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { getProbability } from 'common/calculate'
|
import { getProbability } from 'common/calculate'
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
import { PaperAirplaneIcon } from '@heroicons/react/outline'
|
import { PaperAirplaneIcon } from '@heroicons/react/outline'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { useEvent } from 'web/hooks/use-event'
|
|
||||||
import { Tipper } from '../tipper'
|
import { Tipper } from '../tipper'
|
||||||
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
import { Content, TextEditor, useTextEditor } from '../editor'
|
||||||
|
import { Editor } from '@tiptap/react'
|
||||||
|
|
||||||
export function FeedCommentThread(props: {
|
export function FeedCommentThread(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -39,20 +36,12 @@ export function FeedCommentThread(props: {
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
parentComment: Comment
|
parentComment: Comment
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
truncate?: boolean
|
|
||||||
smallAvatar?: boolean
|
smallAvatar?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const { contract, comments, bets, tips, smallAvatar, parentComment } = props
|
||||||
contract,
|
|
||||||
comments,
|
|
||||||
bets,
|
|
||||||
tips,
|
|
||||||
truncate,
|
|
||||||
smallAvatar,
|
|
||||||
parentComment,
|
|
||||||
} = props
|
|
||||||
const [showReply, setShowReply] = useState(false)
|
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 betsByUserId = groupBy(bets, (bet) => bet.userId)
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const commentsList = comments.filter(
|
const commentsList = comments.filter(
|
||||||
|
@ -60,15 +49,12 @@ export function FeedCommentThread(props: {
|
||||||
parentComment.id && comment.replyToCommentId === parentComment.id
|
parentComment.id && comment.replyToCommentId === parentComment.id
|
||||||
)
|
)
|
||||||
commentsList.unshift(parentComment)
|
commentsList.unshift(parentComment)
|
||||||
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
|
|
||||||
function scrollAndOpenReplyInput(comment: Comment) {
|
function scrollAndOpenReplyInput(comment: Comment) {
|
||||||
setReplyToUsername(comment.userUsername)
|
setReplyToUser({ id: comment.userId, username: comment.userUsername })
|
||||||
setShowReply(true)
|
setShowReply(true)
|
||||||
inputRef?.focus()
|
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
|
||||||
if (showReply && inputRef) inputRef.focus()
|
|
||||||
}, [inputRef, showReply])
|
|
||||||
return (
|
return (
|
||||||
<Col className={'w-full gap-3 pr-1'}>
|
<Col className={'w-full gap-3 pr-1'}>
|
||||||
<span
|
<span
|
||||||
|
@ -81,7 +67,6 @@ export function FeedCommentThread(props: {
|
||||||
betsByUserId={betsByUserId}
|
betsByUserId={betsByUserId}
|
||||||
tips={tips}
|
tips={tips}
|
||||||
smallAvatar={smallAvatar}
|
smallAvatar={smallAvatar}
|
||||||
truncate={truncate}
|
|
||||||
bets={bets}
|
bets={bets}
|
||||||
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
|
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
|
||||||
/>
|
/>
|
||||||
|
@ -98,13 +83,9 @@ export function FeedCommentThread(props: {
|
||||||
(c) => c.userId === user?.id
|
(c) => c.userId === user?.id
|
||||||
)}
|
)}
|
||||||
parentCommentId={parentComment.id}
|
parentCommentId={parentComment.id}
|
||||||
replyToUsername={replyToUsername}
|
replyToUser={replyToUser}
|
||||||
parentAnswerOutcome={comments[0].answerOutcome}
|
parentAnswerOutcome={comments[0].answerOutcome}
|
||||||
setRef={setInputRef}
|
onSubmitComment={() => setShowReply(false)}
|
||||||
onSubmitComment={() => {
|
|
||||||
setShowReply(false)
|
|
||||||
setReplyToUsername('')
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
|
@ -121,14 +102,12 @@ export function CommentRepliesList(props: {
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
treatFirstIndexEqually?: boolean
|
treatFirstIndexEqually?: boolean
|
||||||
smallAvatar?: boolean
|
smallAvatar?: boolean
|
||||||
truncate?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
contract,
|
contract,
|
||||||
commentsList,
|
commentsList,
|
||||||
betsByUserId,
|
betsByUserId,
|
||||||
tips,
|
tips,
|
||||||
truncate,
|
|
||||||
smallAvatar,
|
smallAvatar,
|
||||||
bets,
|
bets,
|
||||||
scrollAndOpenReplyInput,
|
scrollAndOpenReplyInput,
|
||||||
|
@ -168,7 +147,6 @@ export function CommentRepliesList(props: {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
smallAvatar={smallAvatar}
|
smallAvatar={smallAvatar}
|
||||||
truncate={truncate}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
@ -182,7 +160,6 @@ export function FeedComment(props: {
|
||||||
tips: CommentTips
|
tips: CommentTips
|
||||||
betsBySameUser: Bet[]
|
betsBySameUser: Bet[]
|
||||||
probAtCreatedTime?: number
|
probAtCreatedTime?: number
|
||||||
truncate?: boolean
|
|
||||||
smallAvatar?: boolean
|
smallAvatar?: boolean
|
||||||
onReplyClick?: (comment: Comment) => void
|
onReplyClick?: (comment: Comment) => void
|
||||||
}) {
|
}) {
|
||||||
|
@ -192,10 +169,10 @@ export function FeedComment(props: {
|
||||||
tips,
|
tips,
|
||||||
betsBySameUser,
|
betsBySameUser,
|
||||||
probAtCreatedTime,
|
probAtCreatedTime,
|
||||||
truncate,
|
|
||||||
onReplyClick,
|
onReplyClick,
|
||||||
} = props
|
} = props
|
||||||
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
|
||||||
|
comment
|
||||||
let betOutcome: string | undefined,
|
let betOutcome: string | undefined,
|
||||||
bought: string | undefined,
|
bought: string | undefined,
|
||||||
money: string | undefined
|
money: string | undefined
|
||||||
|
@ -276,11 +253,9 @@ export function FeedComment(props: {
|
||||||
elementId={comment.id}
|
elementId={comment.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<TruncatedComment
|
<div className="mt-2 text-[15px] text-gray-700">
|
||||||
comment={text}
|
<Content content={content || text} smallImage />
|
||||||
moreHref={contractPath(contract)}
|
</div>
|
||||||
shouldTruncate={truncate}
|
|
||||||
/>
|
|
||||||
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
|
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
|
||||||
<Tipper comment={comment} tips={tips ?? {}} />
|
<Tipper comment={comment} tips={tips ?? {}} />
|
||||||
{onReplyClick && (
|
{onReplyClick && (
|
||||||
|
@ -345,8 +320,7 @@ export function CommentInput(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
betsByCurrentUser: Bet[]
|
betsByCurrentUser: Bet[]
|
||||||
commentsByCurrentUser: Comment[]
|
commentsByCurrentUser: Comment[]
|
||||||
replyToUsername?: string
|
replyToUser?: { id: string; username: string }
|
||||||
setRef?: (ref: HTMLTextAreaElement) => void
|
|
||||||
// Reply to a free response answer
|
// Reply to a free response answer
|
||||||
parentAnswerOutcome?: string
|
parentAnswerOutcome?: string
|
||||||
// Reply to another comment
|
// Reply to another comment
|
||||||
|
@ -359,12 +333,18 @@ export function CommentInput(props: {
|
||||||
commentsByCurrentUser,
|
commentsByCurrentUser,
|
||||||
parentAnswerOutcome,
|
parentAnswerOutcome,
|
||||||
parentCommentId,
|
parentCommentId,
|
||||||
replyToUsername,
|
replyToUser,
|
||||||
onSubmitComment,
|
onSubmitComment,
|
||||||
setRef,
|
|
||||||
} = props
|
} = props
|
||||||
const user = useUser()
|
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 [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
const mostRecentCommentableBet = getMostRecentCommentableBet(
|
const mostRecentCommentableBet = getMostRecentCommentableBet(
|
||||||
|
@ -380,18 +360,17 @@ export function CommentInput(props: {
|
||||||
track('sign in to comment')
|
track('sign in to comment')
|
||||||
return await firebaseLogin()
|
return await firebaseLogin()
|
||||||
}
|
}
|
||||||
if (!comment || isSubmitting) return
|
if (!editor || editor.isEmpty || isSubmitting) return
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
await createCommentOnContract(
|
await createCommentOnContract(
|
||||||
contract.id,
|
contract.id,
|
||||||
comment,
|
editor.getJSON(),
|
||||||
user,
|
user,
|
||||||
betId,
|
betId,
|
||||||
parentAnswerOutcome,
|
parentAnswerOutcome,
|
||||||
parentCommentId
|
parentCommentId
|
||||||
)
|
)
|
||||||
onSubmitComment?.()
|
onSubmitComment?.()
|
||||||
setComment('')
|
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -415,8 +394,8 @@ export function CommentInput(props: {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={'min-w-0 flex-1'}>
|
<div className={'min-w-0 flex-1'}>
|
||||||
<div className="pl-0.5 text-sm text-gray-500">
|
<div className="pl-0.5 text-sm">
|
||||||
<div className={'mb-1'}>
|
<div className="mb-1 text-gray-500">
|
||||||
{mostRecentCommentableBet && (
|
{mostRecentCommentableBet && (
|
||||||
<BetStatusText
|
<BetStatusText
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
@ -446,14 +425,12 @@ export function CommentInput(props: {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CommentInputTextArea
|
<CommentInputTextArea
|
||||||
commentText={comment}
|
editor={editor}
|
||||||
setComment={setComment}
|
upload={upload}
|
||||||
isReply={!!parentCommentId || !!parentAnswerOutcome}
|
replyToUser={replyToUser}
|
||||||
replyToUsername={replyToUsername ?? ''}
|
|
||||||
user={user}
|
user={user}
|
||||||
submitComment={submitComment}
|
submitComment={submitComment}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
setRef={setRef}
|
|
||||||
presetId={id}
|
presetId={id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -465,94 +442,93 @@ export function CommentInput(props: {
|
||||||
|
|
||||||
export function CommentInputTextArea(props: {
|
export function CommentInputTextArea(props: {
|
||||||
user: User | undefined | null
|
user: User | undefined | null
|
||||||
isReply: boolean
|
replyToUser?: { id: string; username: string }
|
||||||
replyToUsername: string
|
editor: Editor | null
|
||||||
commentText: string
|
upload: Parameters<typeof TextEditor>[0]['upload']
|
||||||
setComment: (text: string) => void
|
|
||||||
submitComment: (id?: string) => void
|
submitComment: (id?: string) => void
|
||||||
isSubmitting: boolean
|
isSubmitting: boolean
|
||||||
setRef?: (ref: HTMLTextAreaElement) => void
|
submitOnEnter?: boolean
|
||||||
presetId?: string
|
presetId?: string
|
||||||
enterToSubmitOnDesktop?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
isReply,
|
|
||||||
setRef,
|
|
||||||
user,
|
user,
|
||||||
commentText,
|
editor,
|
||||||
setComment,
|
upload,
|
||||||
submitComment,
|
submitComment,
|
||||||
presetId,
|
presetId,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
replyToUsername,
|
submitOnEnter,
|
||||||
enterToSubmitOnDesktop,
|
replyToUser,
|
||||||
} = props
|
} = props
|
||||||
const { width } = useWindowSize()
|
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
|
||||||
const memoizedSetComment = useEvent(setComment)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!replyToUsername || !user || replyToUsername === user.username) return
|
editor?.setEditable(!isSubmitting)
|
||||||
const replacement = `@${replyToUsername} `
|
}, [isSubmitting, editor])
|
||||||
memoizedSetComment(replacement + commentText.replace(replacement, ''))
|
|
||||||
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, replyToUsername, memoizedSetComment])
|
}, [editor])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Row className="gap-1.5 text-gray-700">
|
<div>
|
||||||
<Textarea
|
<TextEditor editor={editor} upload={upload}>
|
||||||
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')}>
|
|
||||||
{user && !isSubmitting && (
|
{user && !isSubmitting && (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
|
||||||
'btn btn-ghost btn-sm absolute right-2 bottom-2 flex-row pl-2 capitalize',
|
disabled={!editor || editor.isEmpty}
|
||||||
!commentText && 'pointer-events-none text-gray-500'
|
onClick={submit}
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
submitComment(presetId)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<PaperAirplaneIcon
|
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
|
||||||
className={'m-0 min-w-[22px] rotate-90 p-0 '}
|
|
||||||
height={25}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isSubmitting && (
|
{isSubmitting && (
|
||||||
<LoadingIndicator spinnerClassName={'border-gray-500'} />
|
<LoadingIndicator spinnerClassName={'border-gray-500'} />
|
||||||
)}
|
)}
|
||||||
</Col>
|
</TextEditor>
|
||||||
</Row>
|
</div>
|
||||||
<Row>
|
<Row>
|
||||||
{!user && (
|
{!user && (
|
||||||
<button
|
<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(
|
function getBettorsLargestPositionBeforeTime(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
createdTime: number,
|
createdTime: number,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
// From https://tailwindui.com/components/application-ui/lists/feeds
|
// From https://tailwindui.com/components/application-ui/lists/feeds
|
||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
BanIcon,
|
BanIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
@ -22,7 +22,6 @@ import { UserLink } from '../user-page'
|
||||||
import BetRow from '../bet-row'
|
import BetRow from '../bet-row'
|
||||||
import { Avatar } from '../avatar'
|
import { Avatar } from '../avatar'
|
||||||
import { ActivityItem } from './activity-items'
|
import { ActivityItem } from './activity-items'
|
||||||
import { useSaveSeenContract } from 'web/hooks/use-seen-contracts'
|
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { trackClick } from 'web/lib/firebase/tracking'
|
import { trackClick } from 'web/lib/firebase/tracking'
|
||||||
import { DAY_MS } from 'common/util/time'
|
import { DAY_MS } from 'common/util/time'
|
||||||
|
@ -50,11 +49,8 @@ export function FeedItems(props: {
|
||||||
const { contract, items, className, betRowClassName, user } = props
|
const { contract, items, className, betRowClassName, user } = props
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
|
|
||||||
const [elem, setElem] = useState<HTMLElement | null>(null)
|
|
||||||
useSaveSeenContract(elem, contract)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('flow-root', className)} ref={setElem}>
|
<div className={clsx('flow-root', className)}>
|
||||||
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
|
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
|
||||||
{items.map((item, activityItemIdx) => (
|
{items.map((item, activityItemIdx) => (
|
||||||
<div key={item.id} className={'relative pb-4'}>
|
<div key={item.id} className={'relative pb-4'}>
|
||||||
|
|
7
web/components/fullscreen-confetti.tsx
Normal file
7
web/components/fullscreen-confetti.tsx
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import Confetti, { Props as ConfettiProps } from 'react-confetti'
|
||||||
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
|
||||||
|
export function FullscreenConfetti(props: ConfettiProps) {
|
||||||
|
const { width, height } = useWindowSize()
|
||||||
|
return <Confetti {...props} width={width} height={height} />
|
||||||
|
}
|
|
@ -5,27 +5,23 @@ import React, { useEffect, memo, useState, useMemo } from 'react'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments'
|
import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments'
|
||||||
import {
|
import { CommentInputTextArea } from 'web/components/feed/feed-comments'
|
||||||
CommentInputTextArea,
|
|
||||||
TruncatedComment,
|
|
||||||
} from 'web/components/feed/feed-comments'
|
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||||
|
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { UserLink } from 'web/components/user-page'
|
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 { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||||
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
||||||
import { Tipper } from 'web/components/tipper'
|
import { Tipper } from 'web/components/tipper'
|
||||||
import { sum } from 'lodash'
|
import { sum } from 'lodash'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
import { Content, useTextEditor } from 'web/components/editor'
|
||||||
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
||||||
import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline'
|
import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline'
|
||||||
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
||||||
|
import { usePrivateUser } from 'web/hooks/use-user'
|
||||||
|
|
||||||
export function GroupChat(props: {
|
export function GroupChat(props: {
|
||||||
messages: Comment[]
|
messages: Comment[]
|
||||||
|
@ -34,16 +30,21 @@ export function GroupChat(props: {
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
}) {
|
}) {
|
||||||
const { messages, user, group, tips } = props
|
const { messages, user, group, tips } = props
|
||||||
const [messageText, setMessageText] = useState('')
|
|
||||||
|
const privateUser = usePrivateUser()
|
||||||
|
|
||||||
|
const { editor, upload } = useTextEditor({
|
||||||
|
simple: true,
|
||||||
|
placeholder: 'Send a message',
|
||||||
|
})
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [scrollToBottomRef, setScrollToBottomRef] =
|
const [scrollToBottomRef, setScrollToBottomRef] =
|
||||||
useState<HTMLDivElement | null>(null)
|
useState<HTMLDivElement | null>(null)
|
||||||
const [scrollToMessageId, setScrollToMessageId] = useState('')
|
const [scrollToMessageId, setScrollToMessageId] = useState('')
|
||||||
const [scrollToMessageRef, setScrollToMessageRef] =
|
const [scrollToMessageRef, setScrollToMessageRef] =
|
||||||
useState<HTMLDivElement | null>(null)
|
useState<HTMLDivElement | null>(null)
|
||||||
const [replyToUsername, setReplyToUsername] = useState('')
|
const [replyToUser, setReplyToUser] = useState<any>()
|
||||||
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
|
|
||||||
const [groupedMessages, setGroupedMessages] = useState<Comment[]>([])
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isMember = user && group.memberIds.includes(user?.id)
|
const isMember = user && group.memberIds.includes(user?.id)
|
||||||
|
|
||||||
|
@ -54,25 +55,26 @@ export function GroupChat(props: {
|
||||||
const remainingHeight =
|
const remainingHeight =
|
||||||
(height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight
|
(height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight
|
||||||
|
|
||||||
useMemo(() => {
|
// 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.
|
// Group messages with createdTime within 2 minutes of each other.
|
||||||
const tempMessages = []
|
const tempGrouped: Comment[][] = []
|
||||||
for (let i = 0; i < messages.length; i++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const message = messages[i]
|
const message = messages[i]
|
||||||
if (i === 0) tempMessages.push({ ...message })
|
if (i === 0) tempGrouped.push([message])
|
||||||
else {
|
else {
|
||||||
const prevMessage = messages[i - 1]
|
const prevMessage = messages[i - 1]
|
||||||
const diff = message.createdTime - prevMessage.createdTime
|
const diff = message.createdTime - prevMessage.createdTime
|
||||||
const creatorsMatch = message.userId === prevMessage.userId
|
const creatorsMatch = message.userId === prevMessage.userId
|
||||||
if (diff < 2 * 60 * 1000 && creatorsMatch) {
|
if (diff < 2 * 60 * 1000 && creatorsMatch) {
|
||||||
tempMessages[tempMessages.length - 1].text += `\n${message.text}`
|
tempGrouped.at(-1)?.push(message)
|
||||||
} else {
|
} else {
|
||||||
tempMessages.push({ ...message })
|
tempGrouped.push([message])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setGroupedMessages(tempMessages)
|
return tempGrouped
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -94,11 +96,12 @@ export function GroupChat(props: {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// is mobile?
|
// is mobile?
|
||||||
if (inputRef && width && width > 720) inputRef.focus()
|
if (width && width > 720) focusInput()
|
||||||
}, [inputRef, width])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [width])
|
||||||
|
|
||||||
function onReplyClick(comment: Comment) {
|
function onReplyClick(comment: Comment) {
|
||||||
setReplyToUsername(comment.userUsername)
|
setReplyToUser({ id: comment.userId, username: comment.userUsername })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitMessage() {
|
async function submitMessage() {
|
||||||
|
@ -106,13 +109,16 @@ export function GroupChat(props: {
|
||||||
track('sign in to comment')
|
track('sign in to comment')
|
||||||
return await firebaseLogin()
|
return await firebaseLogin()
|
||||||
}
|
}
|
||||||
if (!messageText || isSubmitting) return
|
if (!editor || editor.isEmpty || isSubmitting) return
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
await createCommentOnGroup(group.id, messageText, user)
|
await createCommentOnGroup(group.id, editor.getJSON(), user)
|
||||||
setMessageText('')
|
editor.commands.clearContent()
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
setReplyToUsername('')
|
setReplyToUser(undefined)
|
||||||
inputRef?.focus()
|
focusInput()
|
||||||
|
}
|
||||||
|
function focusInput() {
|
||||||
|
editor?.commands.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -123,20 +129,20 @@ export function GroupChat(props: {
|
||||||
}
|
}
|
||||||
ref={setScrollToBottomRef}
|
ref={setScrollToBottomRef}
|
||||||
>
|
>
|
||||||
{groupedMessages.map((message) => (
|
{groupedMessages.map((messages) => (
|
||||||
<GroupMessage
|
<GroupMessage
|
||||||
user={user}
|
user={user}
|
||||||
key={message.id}
|
key={`group ${messages[0].id}`}
|
||||||
comment={message}
|
comments={messages}
|
||||||
group={group}
|
group={group}
|
||||||
onReplyClick={onReplyClick}
|
onReplyClick={onReplyClick}
|
||||||
highlight={message.id === scrollToMessageId}
|
highlight={messages[0].id === scrollToMessageId}
|
||||||
setRef={
|
setRef={
|
||||||
scrollToMessageId === message.id
|
scrollToMessageId === messages[0].id
|
||||||
? setScrollToMessageRef
|
? setScrollToMessageRef
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
tips={tips[message.id] ?? {}}
|
tips={tips[messages[0].id] ?? {}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
|
@ -144,7 +150,7 @@ export function GroupChat(props: {
|
||||||
No messages yet. Why not{isMember ? ` ` : ' join and '}
|
No messages yet. Why not{isMember ? ` ` : ' join and '}
|
||||||
<button
|
<button
|
||||||
className={'cursor-pointer font-bold text-gray-700'}
|
className={'cursor-pointer font-bold text-gray-700'}
|
||||||
onClick={() => inputRef?.focus()}
|
onClick={focusInput}
|
||||||
>
|
>
|
||||||
add one?
|
add one?
|
||||||
</button>
|
</button>
|
||||||
|
@ -162,19 +168,26 @@ export function GroupChat(props: {
|
||||||
</div>
|
</div>
|
||||||
<div className={'flex-1'}>
|
<div className={'flex-1'}>
|
||||||
<CommentInputTextArea
|
<CommentInputTextArea
|
||||||
commentText={messageText}
|
editor={editor}
|
||||||
setComment={setMessageText}
|
upload={upload}
|
||||||
isReply={false}
|
|
||||||
user={user}
|
user={user}
|
||||||
replyToUsername={replyToUsername}
|
replyToUser={replyToUser}
|
||||||
submitComment={submitMessage}
|
submitComment={submitMessage}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
enterToSubmitOnDesktop={true}
|
submitOnEnter
|
||||||
setRef={setInputRef}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{privateUser && (
|
||||||
|
<GroupChatNotificationsIcon
|
||||||
|
group={group}
|
||||||
|
privateUser={privateUser}
|
||||||
|
shouldSetAsSeen={true}
|
||||||
|
hidden={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -248,6 +261,7 @@ export function GroupChatInBubble(props: {
|
||||||
group={group}
|
group={group}
|
||||||
privateUser={privateUser}
|
privateUser={privateUser}
|
||||||
shouldSetAsSeen={shouldShowChat}
|
shouldSetAsSeen={shouldShowChat}
|
||||||
|
hidden={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
@ -259,8 +273,9 @@ function GroupChatNotificationsIcon(props: {
|
||||||
group: Group
|
group: Group
|
||||||
privateUser: PrivateUser
|
privateUser: PrivateUser
|
||||||
shouldSetAsSeen: boolean
|
shouldSetAsSeen: boolean
|
||||||
|
hidden: boolean
|
||||||
}) {
|
}) {
|
||||||
const { privateUser, group, shouldSetAsSeen } = props
|
const { privateUser, group, shouldSetAsSeen, hidden } = props
|
||||||
const preferredNotificationsForThisGroup = useUnseenPreferredNotifications(
|
const preferredNotificationsForThisGroup = useUnseenPreferredNotifications(
|
||||||
privateUser,
|
privateUser,
|
||||||
{
|
{
|
||||||
|
@ -282,7 +297,9 @@ function GroupChatNotificationsIcon(props: {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
preferredNotificationsForThisGroup.length > 0 && !shouldSetAsSeen
|
!hidden &&
|
||||||
|
preferredNotificationsForThisGroup.length > 0 &&
|
||||||
|
!shouldSetAsSeen
|
||||||
? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500'
|
? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500'
|
||||||
: 'hidden'
|
: 'hidden'
|
||||||
}
|
}
|
||||||
|
@ -292,16 +309,18 @@ function GroupChatNotificationsIcon(props: {
|
||||||
|
|
||||||
const GroupMessage = memo(function GroupMessage_(props: {
|
const GroupMessage = memo(function GroupMessage_(props: {
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
comment: Comment
|
comments: Comment[]
|
||||||
group: Group
|
group: Group
|
||||||
onReplyClick?: (comment: Comment) => void
|
onReplyClick?: (comment: Comment) => void
|
||||||
setRef?: (ref: HTMLDivElement) => void
|
setRef?: (ref: HTMLDivElement) => void
|
||||||
highlight?: boolean
|
highlight?: boolean
|
||||||
tips: CommentTips
|
tips: CommentTips
|
||||||
}) {
|
}) {
|
||||||
const { comment, onReplyClick, group, setRef, highlight, user, tips } = props
|
const { comments, onReplyClick, group, setRef, highlight, user, tips } = props
|
||||||
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
const first = comments[0]
|
||||||
const isCreatorsComment = user && comment.userId === user.id
|
const { id, userUsername, userName, userAvatarUrl, createdTime } = first
|
||||||
|
|
||||||
|
const isCreatorsComment = user && first.userId === user.id
|
||||||
return (
|
return (
|
||||||
<Col
|
<Col
|
||||||
ref={setRef}
|
ref={setRef}
|
||||||
|
@ -331,23 +350,25 @@ const GroupMessage = memo(function GroupMessage_(props: {
|
||||||
prefix={'group'}
|
prefix={'group'}
|
||||||
slug={group.slug}
|
slug={group.slug}
|
||||||
createdTime={createdTime}
|
createdTime={createdTime}
|
||||||
elementId={comment.id}
|
elementId={id}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Row className={'text-black'}>
|
<div className="mt-2 text-base text-black">
|
||||||
<TruncatedComment
|
{comments.map((comment) => (
|
||||||
comment={text}
|
<Content
|
||||||
moreHref={groupPath(group.slug)}
|
key={comment.id}
|
||||||
shouldTruncate={false}
|
content={comment.content || comment.text}
|
||||||
|
smallImage
|
||||||
/>
|
/>
|
||||||
</Row>
|
))}
|
||||||
|
</div>
|
||||||
<Row>
|
<Row>
|
||||||
{!isCreatorsComment && onReplyClick && (
|
{!isCreatorsComment && onReplyClick && (
|
||||||
<button
|
<button
|
||||||
className={
|
className={
|
||||||
'self-start py-1 text-xs font-bold text-gray-500 hover:underline'
|
'self-start py-1 text-xs font-bold text-gray-500 hover:underline'
|
||||||
}
|
}
|
||||||
onClick={() => onReplyClick(comment)}
|
onClick={() => onReplyClick(first)}
|
||||||
>
|
>
|
||||||
Reply
|
Reply
|
||||||
</button>
|
</button>
|
||||||
|
@ -357,7 +378,7 @@ const GroupMessage = memo(function GroupMessage_(props: {
|
||||||
{formatMoney(sum(Object.values(tips)))}
|
{formatMoney(sum(Object.values(tips)))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isCreatorsComment && <Tipper comment={comment} tips={tips} />}
|
{!isCreatorsComment && <Tipper comment={first} tips={tips} />}
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import { InformationCircleIcon } from '@heroicons/react/outline'
|
import { InformationCircleIcon } from '@heroicons/react/outline'
|
||||||
|
import { Tooltip } from './tooltip'
|
||||||
|
|
||||||
export function InfoTooltip(props: { text: string }) {
|
export function InfoTooltip(props: { text: string }) {
|
||||||
const { text } = props
|
const { text } = props
|
||||||
return (
|
return (
|
||||||
<div className="tooltip" data-tip={text}>
|
<Tooltip text={text}>
|
||||||
<InformationCircleIcon className="h-5 w-5 text-gray-500" />
|
<InformationCircleIcon className="h-5 w-5 text-gray-500" />
|
||||||
</div>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { Contract } from 'common/contract'
|
||||||
|
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
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 { Col } from './layout/col'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { withTracking } from 'web/lib/service/analytics'
|
import { withTracking } from 'web/lib/service/analytics'
|
||||||
|
@ -59,11 +59,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
|
||||||
<SparklesIcon className="inline h-5 w-5" aria-hidden="true" />
|
<SparklesIcon className="inline h-5 w-5" aria-hidden="true" />
|
||||||
Trending markets
|
Trending markets
|
||||||
</Row>
|
</Row>
|
||||||
<ContractsGrid
|
<ContractsGrid contracts={hotContracts?.slice(0, 10) || []} />
|
||||||
contracts={hotContracts?.slice(0, 10) || []}
|
|
||||||
loadMore={() => {}}
|
|
||||||
hasMore={false}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,6 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
|
||||||
className,
|
className,
|
||||||
currentPageForAnalytics,
|
currentPageForAnalytics,
|
||||||
} = props
|
} = props
|
||||||
const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav
|
<nav
|
||||||
|
@ -64,7 +63,11 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
{activeTab?.content}
|
{tabs.map((tab, i) => (
|
||||||
|
<div key={i} className={i === activeIndex ? 'block' : 'hidden'}>
|
||||||
|
{tab.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,7 +33,7 @@ function getNavigation() {
|
||||||
|
|
||||||
const signedOutNavigation = [
|
const signedOutNavigation = [
|
||||||
{ name: 'Home', href: '/', icon: HomeIcon },
|
{ name: 'Home', href: '/', icon: HomeIcon },
|
||||||
{ name: 'Explore', href: '/markets', icon: SearchIcon },
|
{ name: 'Explore', href: '/home', icon: SearchIcon },
|
||||||
]
|
]
|
||||||
|
|
||||||
// From https://codepen.io/chris__sev/pen/QWGvYbL
|
// From https://codepen.io/chris__sev/pen/QWGvYbL
|
||||||
|
@ -44,7 +44,7 @@ export function BottomNavBar() {
|
||||||
const currentPage = router.pathname
|
const currentPage = router.pathname
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const privateUser = usePrivateUser(user?.id)
|
const privateUser = usePrivateUser()
|
||||||
|
|
||||||
const isIframe = useIsIframe()
|
const isIframe = useIsIframe()
|
||||||
if (isIframe) {
|
if (isIframe) {
|
||||||
|
|
|
@ -30,6 +30,7 @@ import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
||||||
import { PrivateUser } from 'common/user'
|
import { PrivateUser } from 'common/user'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||||
|
import { buildArray } from 'common/util/array'
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
// log out, and then reload the page, in case SSR wants to boot them out
|
// log out, and then reload the page, in case SSR wants to boot them out
|
||||||
|
@ -61,42 +62,31 @@ function getMoreNavigation(user?: User | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
if (CHALLENGES_ENABLED)
|
return buildArray(
|
||||||
return [
|
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
|
||||||
{ 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: 'Charity', href: '/charity' },
|
||||||
|
{
|
||||||
|
name: 'Salem tournament',
|
||||||
|
href: 'https://salemcenter.manifold.markets/',
|
||||||
|
},
|
||||||
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
||||||
]
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CHALLENGES_ENABLED)
|
return buildArray(
|
||||||
return [
|
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
|
||||||
{ 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: 'Referrals', href: '/referrals' },
|
||||||
{ name: 'Charity', href: '/charity' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
{ name: 'Send M$', href: '/links' },
|
{ name: 'Send M$', href: '/links' },
|
||||||
|
{
|
||||||
|
name: 'Salem tournament',
|
||||||
|
href: 'https://salemcenter.manifold.markets/',
|
||||||
|
},
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||||
{
|
{
|
||||||
|
@ -105,11 +95,12 @@ function getMoreNavigation(user?: User | null) {
|
||||||
onClick: logout,
|
onClick: logout,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const signedOutNavigation = [
|
const signedOutNavigation = [
|
||||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
{ name: 'Home', href: '/', icon: HomeIcon },
|
||||||
{ name: 'Explore', href: '/markets', icon: SearchIcon },
|
{ name: 'Explore', href: '/home', icon: SearchIcon },
|
||||||
{
|
{
|
||||||
name: 'About',
|
name: 'About',
|
||||||
href: 'https://docs.manifold.markets/$how-to',
|
href: 'https://docs.manifold.markets/$how-to',
|
||||||
|
@ -141,29 +132,27 @@ const signedInMobileNavigation = [
|
||||||
]
|
]
|
||||||
|
|
||||||
function getMoreMobileNav() {
|
function getMoreMobileNav() {
|
||||||
return [
|
const signOut = {
|
||||||
...(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' },
|
|
||||||
{ name: 'Send M$', href: '/links' },
|
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
|
||||||
]),
|
|
||||||
{
|
|
||||||
name: 'Sign out',
|
name: 'Sign out',
|
||||||
href: '#',
|
href: '#',
|
||||||
onClick: logout,
|
onClick: logout,
|
||||||
|
}
|
||||||
|
if (IS_PRIVATE_MANIFOLD) return [signOut]
|
||||||
|
|
||||||
|
return buildArray<Item>(
|
||||||
|
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
|
||||||
|
[
|
||||||
|
{ name: 'Referrals', href: '/referrals' },
|
||||||
|
{
|
||||||
|
name: 'Salem tournament',
|
||||||
|
href: 'https://salemcenter.manifold.markets/',
|
||||||
},
|
},
|
||||||
]
|
{ name: 'Charity', href: '/charity' },
|
||||||
|
{ name: 'Send M$', href: '/links' },
|
||||||
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
|
],
|
||||||
|
signOut
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Item = {
|
export type Item = {
|
||||||
|
@ -232,7 +221,7 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
const currentPage = router.pathname
|
const currentPage = router.pathname
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const privateUser = usePrivateUser(user?.id)
|
const privateUser = usePrivateUser()
|
||||||
// usePing(user?.id)
|
// usePing(user?.id)
|
||||||
|
|
||||||
const navigationOptions = !user ? signedOutNavigation : getNavigation()
|
const navigationOptions = !user ? signedOutNavigation : getNavigation()
|
||||||
|
@ -328,8 +317,7 @@ function GroupsList(props: {
|
||||||
|
|
||||||
const { height } = useWindowSize()
|
const { height } = useWindowSize()
|
||||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||||
const remainingHeight =
|
const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0)
|
||||||
(height ?? window.innerHeight) - (containerRef?.offsetTop ?? 0)
|
|
||||||
|
|
||||||
const notifIsForThisItem = useMemo(
|
const notifIsForThisItem = useMemo(
|
||||||
() => (itemHref: string) =>
|
() => (itemHref: string) =>
|
||||||
|
|
|
@ -2,15 +2,14 @@ import { BellIcon } from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
import { usePrivateUser } from 'web/hooks/use-user'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useUnseenPreferredNotificationGroups } from 'web/hooks/use-notifications'
|
import { useUnseenPreferredNotificationGroups } from 'web/hooks/use-notifications'
|
||||||
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
||||||
import { PrivateUser } from 'common/user'
|
import { PrivateUser } from 'common/user'
|
||||||
|
|
||||||
export default function NotificationsIcon(props: { className?: string }) {
|
export default function NotificationsIcon(props: { className?: string }) {
|
||||||
const user = useUser()
|
const privateUser = usePrivateUser()
|
||||||
const privateUser = usePrivateUser(user?.id)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className={clsx('justify-center')}>
|
<Row className={clsx('justify-center')}>
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { ReactNode } from 'react'
|
|
||||||
import { Answer } from 'common/answer'
|
import { Answer } from 'common/answer'
|
||||||
import { getProbability } from 'common/calculate'
|
import { getProbability } from 'common/calculate'
|
||||||
import { getValueFromBucket } from 'common/calculate-dpm'
|
import { getValueFromBucket } from 'common/calculate-dpm'
|
||||||
|
@ -11,7 +10,7 @@ import {
|
||||||
resolution,
|
resolution,
|
||||||
} from 'common/contract'
|
} from 'common/contract'
|
||||||
import { formatLargeNumber, formatPercent } from 'common/util/format'
|
import { formatLargeNumber, formatPercent } from 'common/util/format'
|
||||||
import { ClientRender } from './client-render'
|
import { Tooltip } from './tooltip'
|
||||||
|
|
||||||
export function OutcomeLabel(props: {
|
export function OutcomeLabel(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -91,13 +90,13 @@ export function FreeResponseOutcomeLabel(props: {
|
||||||
const chosen = contract.answers?.find((answer) => answer.id === resolution)
|
const chosen = contract.answers?.find((answer) => answer.id === resolution)
|
||||||
if (!chosen) return <AnswerNumberLabel number={resolution} />
|
if (!chosen) return <AnswerNumberLabel number={resolution} />
|
||||||
return (
|
return (
|
||||||
<FreeResponseAnswerToolTip text={chosen.text}>
|
<Tooltip text={chosen.text}>
|
||||||
<AnswerLabel
|
<AnswerLabel
|
||||||
answer={chosen}
|
answer={chosen}
|
||||||
truncate={truncate}
|
truncate={truncate}
|
||||||
className={answerClassName}
|
className={answerClassName}
|
||||||
/>
|
/>
|
||||||
</FreeResponseAnswerToolTip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,23 +173,3 @@ export function AnswerLabel(props: {
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FreeResponseAnswerToolTip(props: {
|
|
||||||
text: string
|
|
||||||
children?: ReactNode
|
|
||||||
}) {
|
|
||||||
const { text } = props
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ClientRender>
|
|
||||||
<span
|
|
||||||
className="tooltip hidden cursor-default sm:inline-block"
|
|
||||||
data-tip={text}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</span>
|
|
||||||
</ClientRender>
|
|
||||||
<span className="whitespace-nowrap sm:hidden">{props.children}</span>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -61,7 +61,8 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
||||||
min: Math.min(...points.map((p) => p.y)),
|
min: Math.min(...points.map((p) => p.y)),
|
||||||
}}
|
}}
|
||||||
gridYValues={numYTickValues}
|
gridYValues={numYTickValues}
|
||||||
curve="monotoneX"
|
curve="stepAfter"
|
||||||
|
enablePoints={false}
|
||||||
colors={{ datum: 'color' }}
|
colors={{ datum: 'color' }}
|
||||||
axisBottom={{
|
axisBottom={{
|
||||||
tickValues: numXTickValues,
|
tickValues: numXTickValues,
|
||||||
|
|
|
@ -1,20 +1,27 @@
|
||||||
import { PortfolioMetrics } from 'common/user'
|
import { PortfolioMetrics } from 'common/user'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { last } from 'lodash'
|
import { last } from 'lodash'
|
||||||
import { memo, useState } from 'react'
|
import { memo, useEffect, useState } from 'react'
|
||||||
import { Period } from 'web/lib/firebase/users'
|
import { Period, getPortfolioHistory } from 'web/lib/firebase/users'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { PortfolioValueGraph } from './portfolio-value-graph'
|
import { PortfolioValueGraph } from './portfolio-value-graph'
|
||||||
|
|
||||||
export const PortfolioValueSection = memo(
|
export const PortfolioValueSection = memo(
|
||||||
function PortfolioValueSection(props: {
|
function PortfolioValueSection(props: {
|
||||||
portfolioHistory: PortfolioMetrics[]
|
userId: string
|
||||||
disableSelector?: boolean
|
disableSelector?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { portfolioHistory, disableSelector } = props
|
const { disableSelector, userId } = props
|
||||||
const lastPortfolioMetrics = last(portfolioHistory)
|
|
||||||
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
|
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
|
||||||
|
const [portfolioHistory, setUsersPortfolioHistory] = useState<
|
||||||
|
PortfolioMetrics[]
|
||||||
|
>([])
|
||||||
|
useEffect(() => {
|
||||||
|
getPortfolioHistory(userId).then(setUsersPortfolioHistory)
|
||||||
|
}, [userId])
|
||||||
|
const lastPortfolioMetrics = last(portfolioHistory)
|
||||||
|
|
||||||
if (portfolioHistory.length === 0 || !lastPortfolioMetrics) {
|
if (portfolioHistory.length === 0 || !lastPortfolioMetrics) {
|
||||||
return <></>
|
return <></>
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
import { DateTimeTooltip } from './datetime-tooltip'
|
import { DateTimeTooltip } from './datetime-tooltip'
|
||||||
import { fromNow } from 'web/lib/util/time'
|
import dayjs from 'dayjs'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
export function RelativeTimestamp(props: { time: number }) {
|
export function RelativeTimestamp(props: { time: number }) {
|
||||||
const { time } = props
|
const { time } = props
|
||||||
|
const dayJsTime = dayjs(time)
|
||||||
return (
|
return (
|
||||||
<DateTimeTooltip time={time}>
|
<DateTimeTooltip
|
||||||
<span className="ml-1 whitespace-nowrap text-gray-400">
|
className="ml-1 whitespace-nowrap text-gray-400"
|
||||||
{fromNow(time)}
|
time={dayJsTime}
|
||||||
</span>
|
>
|
||||||
|
{dayJsTime.fromNow()}
|
||||||
</DateTimeTooltip>
|
</DateTimeTooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,35 +1,35 @@
|
||||||
import React, { Fragment } from 'react'
|
import React from 'react'
|
||||||
import { CodeIcon } from '@heroicons/react/outline'
|
import { CodeIcon } from '@heroicons/react/outline'
|
||||||
import { Menu, Transition } from '@headlessui/react'
|
import { Menu } from '@headlessui/react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { contractPath } from 'web/lib/firebase/contracts'
|
import { contractPath } from 'web/lib/firebase/contracts'
|
||||||
import { DOMAIN } from 'common/envs/constants'
|
import { DOMAIN } from 'common/envs/constants'
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
import { ToastClipboard } from 'web/components/toast-clipboard'
|
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
|
||||||
function copyEmbedCode(contract: Contract) {
|
export function embedCode(contract: Contract) {
|
||||||
const title = contract.question
|
const title = contract.question
|
||||||
const src = `https://${DOMAIN}/embed${contractPath(contract)}`
|
const src = `https://${DOMAIN}/embed${contractPath(contract)}`
|
||||||
|
|
||||||
const embedCode = `<iframe width="560" height="405" src="${src}" title="${title}" frameborder="0"></iframe>`
|
return `<iframe width="560" height="405" src="${src}" title="${title}" frameborder="0"></iframe>`
|
||||||
|
|
||||||
copyToClipboard(embedCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShareEmbedButton(props: {
|
export function ShareEmbedButton(props: { contract: Contract }) {
|
||||||
contract: Contract
|
const { contract } = props
|
||||||
toastClassName?: string
|
|
||||||
}) {
|
const codeIcon = <CodeIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||||
const { contract, toastClassName } = props
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
as="div"
|
as="div"
|
||||||
className="relative z-10 flex-shrink-0"
|
className="relative z-10 flex-shrink-0"
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
copyEmbedCode(contract)
|
copyToClipboard(embedCode(contract))
|
||||||
|
toast.success('Embed code copied!', {
|
||||||
|
icon: codeIcon,
|
||||||
|
})
|
||||||
track('copy embed code')
|
track('copy embed code')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -41,25 +41,9 @@ export function ShareEmbedButton(props: {
|
||||||
color: '#9ca3af', // text-gray-400
|
color: '#9ca3af', // text-gray-400
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CodeIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
{codeIcon}
|
||||||
Embed
|
Embed
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
enter="transition ease-out duration-100"
|
|
||||||
enterFrom="transform opacity-0 scale-95"
|
|
||||||
enterTo="transform opacity-100 scale-100"
|
|
||||||
leave="transition ease-in duration-75"
|
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
|
||||||
leaveTo="transform opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<Menu.Items>
|
|
||||||
<Menu.Item>
|
|
||||||
<ToastClipboard className={toastClassName} />
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Items>
|
|
||||||
</Transition>
|
|
||||||
</Menu>
|
</Menu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ShareIcon } from '@heroicons/react/outline'
|
import { LinkIcon } from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
|
@ -40,7 +40,7 @@ export function ShareIconButton(props: {
|
||||||
setTimeout(() => setShowToast(false), 2000)
|
setTimeout(() => setShowToast(false), 2000)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ShareIcon
|
<LinkIcon
|
||||||
className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')}
|
className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -37,7 +37,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||||
|
|
||||||
// declare debounced function only on first render
|
// declare debounced function only on first render
|
||||||
const [saveTip] = useState(() =>
|
const [saveTip] = useState(() =>
|
||||||
debounce(async (user: User, change: number) => {
|
debounce(async (user: User, comment: Comment, change: number) => {
|
||||||
if (change === 0) {
|
if (change === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -71,30 +71,24 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||||
// instant save on unrender
|
// instant save on unrender
|
||||||
useEffect(() => () => void saveTip.flush(), [saveTip])
|
useEffect(() => () => void saveTip.flush(), [saveTip])
|
||||||
|
|
||||||
const changeTip = (tip: number) => {
|
const addTip = (delta: number) => {
|
||||||
setLocalTip(tip)
|
setLocalTip(localTip + delta)
|
||||||
me && saveTip(me, tip - savedTip)
|
me && saveTip(me, comment, localTip - savedTip + delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canDown = me && localTip > savedTip
|
||||||
|
const canUp = me && me.id !== comment.userId && me.balance >= localTip + 5
|
||||||
return (
|
return (
|
||||||
<Row className="items-center gap-0.5">
|
<Row className="items-center gap-0.5">
|
||||||
<DownTip
|
<DownTip onClick={canDown ? () => addTip(-5) : undefined} />
|
||||||
value={localTip}
|
|
||||||
onChange={changeTip}
|
|
||||||
disabled={!me || localTip <= savedTip}
|
|
||||||
/>
|
|
||||||
<span className="font-bold">{Math.floor(total)}</span>
|
<span className="font-bold">{Math.floor(total)}</span>
|
||||||
<UpTip
|
<UpTip onClick={canUp ? () => addTip(+5) : undefined} value={localTip} />
|
||||||
value={localTip}
|
|
||||||
onChange={changeTip}
|
|
||||||
disabled={!me || me.id === comment.userId || me.balance < localTip + 5}
|
|
||||||
/>
|
|
||||||
{localTip === 0 ? (
|
{localTip === 0 ? (
|
||||||
''
|
''
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'font-semibold',
|
'ml-1 font-semibold',
|
||||||
localTip > 0 ? 'text-primary' : 'text-red-400'
|
localTip > 0 ? 'text-primary' : 'text-red-400'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -105,21 +99,19 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DownTip(prop: {
|
function DownTip(props: { onClick?: () => void }) {
|
||||||
value: number
|
const { onClick } = props
|
||||||
onChange: (tip: number) => void
|
|
||||||
disabled?: boolean
|
|
||||||
}) {
|
|
||||||
const { onChange, value, disabled } = prop
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
className="tooltip-bottom"
|
className="h-6 w-6"
|
||||||
text={!disabled && `-${formatMoney(5)}`}
|
placement="bottom"
|
||||||
|
text={onClick && `-${formatMoney(5)}`}
|
||||||
|
noTap
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="flex h-max items-center hover:text-red-600 disabled:text-gray-300"
|
className="hover:text-red-600 disabled:text-gray-300"
|
||||||
disabled={disabled}
|
disabled={!onClick}
|
||||||
onClick={() => onChange(value - 5)}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="h-6 w-6" />
|
<ChevronLeftIcon className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -127,30 +119,22 @@ function DownTip(prop: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function UpTip(prop: {
|
function UpTip(props: { onClick?: () => void; value: number }) {
|
||||||
value: number
|
const { onClick, value } = props
|
||||||
onChange: (tip: number) => void
|
const IconKind = value >= 10 ? ChevronDoubleRightIcon : ChevronRightIcon
|
||||||
disabled?: boolean
|
|
||||||
}) {
|
|
||||||
const { onChange, value, disabled } = prop
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
className="tooltip-bottom"
|
className="h-6 w-6"
|
||||||
text={!disabled && `Tip ${formatMoney(5)}`}
|
placement="bottom"
|
||||||
|
text={onClick && `Tip ${formatMoney(5)}`}
|
||||||
|
noTap
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="hover:text-primary flex h-max items-center disabled:text-gray-300"
|
className="hover:text-primary disabled:text-gray-300"
|
||||||
disabled={disabled}
|
disabled={!onClick}
|
||||||
onClick={() => onChange(value + 5)}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{value >= 10 ? (
|
<IconKind className={clsx('h-6 w-6', value ? 'text-primary' : '')} />
|
||||||
<ChevronDoubleRightIcon className="text-primary mx-1 h-6 w-6" />
|
|
||||||
) : value > 0 ? (
|
|
||||||
<ChevronRightIcon className="text-primary h-6 w-6" />
|
|
||||||
) : (
|
|
||||||
<ChevronRightIcon className="h-6 w-6" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,15 +1,104 @@
|
||||||
|
import {
|
||||||
|
arrow,
|
||||||
|
autoUpdate,
|
||||||
|
flip,
|
||||||
|
offset,
|
||||||
|
Placement,
|
||||||
|
shift,
|
||||||
|
useFloating,
|
||||||
|
useFocus,
|
||||||
|
useHover,
|
||||||
|
useInteractions,
|
||||||
|
useRole,
|
||||||
|
} from '@floating-ui/react-dom-interactions'
|
||||||
|
import { Transition } from '@headlessui/react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { ReactNode, useRef, useState } from 'react'
|
||||||
|
|
||||||
export function Tooltip(
|
// See https://floating-ui.com/docs/react-dom
|
||||||
props: {
|
|
||||||
|
export function Tooltip(props: {
|
||||||
text: string | false | undefined | null
|
text: string | false | undefined | null
|
||||||
} & JSX.IntrinsicElements['div']
|
children: ReactNode
|
||||||
) {
|
className?: string
|
||||||
const { text, children, className } = props
|
placement?: Placement
|
||||||
|
noTap?: boolean
|
||||||
|
}) {
|
||||||
|
const { text, children, className, placement = 'top', noTap } = props
|
||||||
|
|
||||||
|
const arrowRef = useRef(null)
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const { x, y, reference, floating, strategy, middlewareData, context } =
|
||||||
|
useFloating({
|
||||||
|
open,
|
||||||
|
onOpenChange: setOpen,
|
||||||
|
whileElementsMounted: autoUpdate,
|
||||||
|
placement,
|
||||||
|
middleware: [
|
||||||
|
offset(8),
|
||||||
|
flip(),
|
||||||
|
shift({ padding: 4 }),
|
||||||
|
arrow({ element: arrowRef }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { x: arrowX, y: arrowY } = middlewareData.arrow ?? {}
|
||||||
|
|
||||||
|
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||||
|
useHover(context, { mouseOnly: noTap }),
|
||||||
|
useFocus(context),
|
||||||
|
useRole(context, { role: 'tooltip' }),
|
||||||
|
])
|
||||||
|
// which side of tooltip arrow is on. like: if tooltip is top-left, arrow is on bottom of tooltip
|
||||||
|
const arrowSide = {
|
||||||
|
top: 'bottom',
|
||||||
|
right: 'left',
|
||||||
|
bottom: 'top',
|
||||||
|
left: 'right ',
|
||||||
|
}[placement.split('-')[0]] as string
|
||||||
|
|
||||||
return text ? (
|
return text ? (
|
||||||
<div className={clsx(className, 'tooltip z-10')} data-tip={text}>
|
<div className="contents">
|
||||||
|
<div
|
||||||
|
className={clsx('inline-block', className)}
|
||||||
|
ref={reference}
|
||||||
|
tabIndex={noTap ? undefined : 0}
|
||||||
|
{...getReferenceProps()}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
{/* conditionally render tooltip and fade in/out */}
|
||||||
|
<Transition
|
||||||
|
show={open}
|
||||||
|
enter="transition ease-out duration-200"
|
||||||
|
enterFrom="opacity-0 "
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="transition ease-in duration-150"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
// div attributes
|
||||||
|
role="tooltip"
|
||||||
|
ref={floating}
|
||||||
|
style={{ position: strategy, top: y ?? 0, left: x ?? 0 }}
|
||||||
|
className="z-10 max-w-xs rounded bg-slate-700 px-2 py-1 text-center text-sm text-white"
|
||||||
|
{...getFloatingProps()}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
<div
|
||||||
|
ref={arrowRef}
|
||||||
|
className="absolute h-2 w-2 rotate-45 bg-slate-700"
|
||||||
|
style={{
|
||||||
|
top: arrowY != null ? arrowY : '',
|
||||||
|
left: arrowX != null ? arrowX : '',
|
||||||
|
right: '',
|
||||||
|
bottom: '',
|
||||||
|
[arrowSide]: '-4px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>{children}</>
|
<>{children}</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,18 +1,12 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { Dictionary, keyBy, uniq } from 'lodash'
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { LinkIcon } from '@heroicons/react/solid'
|
import { LinkIcon } from '@heroicons/react/solid'
|
||||||
import { PencilIcon } from '@heroicons/react/outline'
|
import { PencilIcon } from '@heroicons/react/outline'
|
||||||
import Confetti from 'react-confetti'
|
|
||||||
|
|
||||||
import {
|
import { User } from 'web/lib/firebase/users'
|
||||||
follow,
|
import { useUser } from 'web/hooks/use-user'
|
||||||
getPortfolioHistory,
|
import { CreatorContractsList } from './contract/contracts-grid'
|
||||||
unfollow,
|
|
||||||
User,
|
|
||||||
} from 'web/lib/firebase/users'
|
|
||||||
import { CreatorContractsList } from './contract/contracts-list'
|
|
||||||
import { SEO } from './SEO'
|
import { SEO } from './SEO'
|
||||||
import { Page } from './page'
|
import { Page } from './page'
|
||||||
import { SiteLink } from './site-link'
|
import { SiteLink } from './site-link'
|
||||||
|
@ -24,20 +18,12 @@ import { Row } from './layout/row'
|
||||||
import { genHash } from 'common/util/random'
|
import { genHash } from 'common/util/random'
|
||||||
import { QueryUncontrolledTabs } from './layout/tabs'
|
import { QueryUncontrolledTabs } from './layout/tabs'
|
||||||
import { UserCommentsList } from './comments-list'
|
import { UserCommentsList } from './comments-list'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { FullscreenConfetti } from 'web/components/fullscreen-confetti'
|
||||||
import { Comment, getUsersComments } from 'web/lib/firebase/comments'
|
|
||||||
import { Contract } from 'common/contract'
|
|
||||||
import { getContractFromId, listContracts } from 'web/lib/firebase/contracts'
|
|
||||||
import { LoadingIndicator } from './loading-indicator'
|
|
||||||
import { BetsList } from './bets-list'
|
import { BetsList } from './bets-list'
|
||||||
import { FollowersButton, FollowingButton } from './following-button'
|
import { FollowersButton, FollowingButton } from './following-button'
|
||||||
import { useFollows } from 'web/hooks/use-follows'
|
import { UserFollowButton } from './follow-button'
|
||||||
import { FollowButton } from './follow-button'
|
|
||||||
import { PortfolioMetrics } from 'common/user'
|
|
||||||
import { GroupsButton } from 'web/components/groups/groups-button'
|
import { GroupsButton } from 'web/components/groups/groups-button'
|
||||||
import { PortfolioValueSection } from './portfolio/portfolio-value-section'
|
import { PortfolioValueSection } from './portfolio/portfolio-value-section'
|
||||||
import { filterDefined } from 'common/util/array'
|
|
||||||
import { useUserBets } from 'web/hooks/use-user-bets'
|
|
||||||
import { ReferralsButton } from 'web/components/referrals-button'
|
import { ReferralsButton } from 'web/components/referrals-button'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||||
|
@ -48,91 +34,47 @@ export function UserLink(props: {
|
||||||
username: string
|
username: string
|
||||||
showUsername?: boolean
|
showUsername?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
justFirstName?: boolean
|
short?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { name, username, showUsername, className, justFirstName } = props
|
const { name, username, showUsername, className, short } = props
|
||||||
|
const firstName = name.split(' ')[0]
|
||||||
|
const maxLength = 10
|
||||||
|
const shortName =
|
||||||
|
firstName.length >= 3
|
||||||
|
? firstName.length < maxLength
|
||||||
|
? firstName
|
||||||
|
: firstName.substring(0, maxLength - 3) + '...'
|
||||||
|
: name.length > maxLength
|
||||||
|
? name.substring(0, maxLength) + '...'
|
||||||
|
: name
|
||||||
return (
|
return (
|
||||||
<SiteLink
|
<SiteLink
|
||||||
href={`/${username}`}
|
href={`/${username}`}
|
||||||
className={clsx('z-10 truncate', className)}
|
className={clsx('z-10 truncate', className)}
|
||||||
>
|
>
|
||||||
{justFirstName ? name.split(' ')[0] : name}
|
{short ? shortName : name}
|
||||||
{showUsername && ` (@${username})`}
|
{showUsername && ` (@${username})`}
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TAB_IDS = ['markets', 'comments', 'bets', 'groups']
|
export const TAB_IDS = ['markets', 'comments', 'bets', 'groups']
|
||||||
const JUNE_1_2022 = new Date('2022-06-01T00:00:00.000Z').valueOf()
|
|
||||||
|
|
||||||
export function UserPage(props: { user: User; currentUser?: User }) {
|
export function UserPage(props: { user: User }) {
|
||||||
const { user, currentUser } = props
|
const { user } = props
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const currentUser = useUser()
|
||||||
const isCurrentUser = user.id === currentUser?.id
|
const isCurrentUser = user.id === currentUser?.id
|
||||||
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||||
const [usersComments, setUsersComments] = useState<Comment[] | undefined>()
|
|
||||||
const [usersContracts, setUsersContracts] = useState<Contract[] | 'loading'>(
|
|
||||||
'loading'
|
|
||||||
)
|
|
||||||
const userBets = useUserBets(user.id, { includeRedemptions: true })
|
|
||||||
const betCount =
|
|
||||||
userBets === undefined
|
|
||||||
? 0
|
|
||||||
: userBets.filter((bet) => !bet.isRedemption && bet.amount !== 0).length
|
|
||||||
|
|
||||||
const [portfolioHistory, setUsersPortfolioHistory] = useState<
|
|
||||||
PortfolioMetrics[]
|
|
||||||
>([])
|
|
||||||
const [contractsById, setContractsById] = useState<
|
|
||||||
Dictionary<Contract> | undefined
|
|
||||||
>()
|
|
||||||
const [showConfetti, setShowConfetti] = useState(false)
|
const [showConfetti, setShowConfetti] = useState(false)
|
||||||
const { width, height } = useWindowSize()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const claimedMana = router.query['claimed-mana'] === 'yes'
|
const claimedMana = router.query['claimed-mana'] === 'yes'
|
||||||
setShowConfetti(claimedMana)
|
setShowConfetti(claimedMana)
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user) return
|
|
||||||
getUsersComments(user.id).then(setUsersComments)
|
|
||||||
listContracts(user.id).then(setUsersContracts)
|
|
||||||
getPortfolioHistory(user.id).then(setUsersPortfolioHistory)
|
|
||||||
}, [user])
|
|
||||||
|
|
||||||
// TODO: display comments on groups
|
|
||||||
useEffect(() => {
|
|
||||||
if (usersComments && userBets) {
|
|
||||||
const uniqueContractIds = uniq([
|
|
||||||
...usersComments.map((comment) => comment.contractId),
|
|
||||||
...(userBets?.map((bet) => bet.contractId) ?? []),
|
|
||||||
])
|
|
||||||
Promise.all(
|
|
||||||
uniqueContractIds.map((contractId) =>
|
|
||||||
contractId ? getContractFromId(contractId) : undefined
|
|
||||||
)
|
|
||||||
).then((contracts) => {
|
|
||||||
const contractsById = keyBy(filterDefined(contracts), 'id')
|
|
||||||
setContractsById(contractsById)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [userBets, usersComments])
|
|
||||||
|
|
||||||
const yourFollows = useFollows(currentUser?.id)
|
|
||||||
const isFollowing = yourFollows?.includes(user.id)
|
|
||||||
const profit = user.profitCached.allTime
|
const profit = user.profitCached.allTime
|
||||||
|
|
||||||
const onFollow = () => {
|
|
||||||
if (!currentUser) return
|
|
||||||
follow(currentUser.id, user.id)
|
|
||||||
}
|
|
||||||
const onUnfollow = () => {
|
|
||||||
if (!currentUser) return
|
|
||||||
unfollow(currentUser.id, user.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page key={user.id}>
|
<Page key={user.id}>
|
||||||
<SEO
|
<SEO
|
||||||
|
@ -141,12 +83,7 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
||||||
url={`/${user.username}`}
|
url={`/${user.username}`}
|
||||||
/>
|
/>
|
||||||
{showConfetti && (
|
{showConfetti && (
|
||||||
<Confetti
|
<FullscreenConfetti recycle={false} numberOfPieces={300} />
|
||||||
width={width ? width : 500}
|
|
||||||
height={height ? height : 500}
|
|
||||||
recycle={false}
|
|
||||||
numberOfPieces={300}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{/* Banner image up top, with an circle avatar overlaid */}
|
{/* Banner image up top, with an circle avatar overlaid */}
|
||||||
<div
|
<div
|
||||||
|
@ -167,13 +104,7 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
||||||
|
|
||||||
{/* Top right buttons (e.g. edit, follow) */}
|
{/* Top right buttons (e.g. edit, follow) */}
|
||||||
<div className="absolute right-0 top-0 mt-4 mr-4">
|
<div className="absolute right-0 top-0 mt-4 mr-4">
|
||||||
{!isCurrentUser && (
|
{!isCurrentUser && <UserFollowButton userId={user.id} />}
|
||||||
<FollowButton
|
|
||||||
isFollowing={isFollowing}
|
|
||||||
onFollow={onFollow}
|
|
||||||
onUnfollow={onUnfollow}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isCurrentUser && (
|
{isCurrentUser && (
|
||||||
<SiteLink className="btn" href="/profile">
|
<SiteLink className="btn" href="/profile">
|
||||||
<PencilIcon className="h-5 w-5" />{' '}
|
<PencilIcon className="h-5 w-5" />{' '}
|
||||||
|
@ -198,9 +129,7 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
profit
|
profit
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Spacer h={4} />
|
<Spacer h={4} />
|
||||||
|
|
||||||
{user.bio && (
|
{user.bio && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
|
@ -209,7 +138,6 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
||||||
<Spacer h={4} />
|
<Spacer h={4} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Col className="flex-wrap gap-2 sm:flex-row sm:items-center sm:gap-4">
|
<Col className="flex-wrap gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||||
<Row className="gap-4">
|
<Row className="gap-4">
|
||||||
<FollowingButton user={user} />
|
<FollowingButton user={user} />
|
||||||
|
@ -271,7 +199,6 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Spacer h={5} />
|
<Spacer h={5} />
|
||||||
{currentUser?.id === user.id && (
|
{currentUser?.id === user.id && (
|
||||||
<Row
|
<Row
|
||||||
|
@ -280,8 +207,10 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
Refer a friend and earn {formatMoney(500)} when they sign up! You
|
<SiteLink href="/referrals">
|
||||||
have <ReferralsButton user={user} currentUser={currentUser} />
|
Refer a friend and earn {formatMoney(500)} when they sign up!
|
||||||
|
</SiteLink>{' '}
|
||||||
|
You have <ReferralsButton user={user} currentUser={currentUser} />
|
||||||
</span>
|
</span>
|
||||||
<ShareIconButton
|
<ShareIconButton
|
||||||
copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`}
|
copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`}
|
||||||
|
@ -292,58 +221,31 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
<Spacer h={5} />
|
<Spacer h={5} />
|
||||||
|
|
||||||
{usersContracts !== 'loading' && contractsById && usersComments ? (
|
|
||||||
<QueryUncontrolledTabs
|
<QueryUncontrolledTabs
|
||||||
currentPageForAnalytics={'profile'}
|
currentPageForAnalytics={'profile'}
|
||||||
labelClassName={'pb-2 pt-1 '}
|
labelClassName={'pb-2 pt-1 '}
|
||||||
tabs={[
|
tabs={[
|
||||||
{
|
{
|
||||||
title: 'Markets',
|
title: 'Markets',
|
||||||
content: <CreatorContractsList creator={user} />,
|
content: (
|
||||||
tabIcon: (
|
<CreatorContractsList user={currentUser} creator={user} />
|
||||||
<span className="px-0.5 font-bold">
|
|
||||||
{usersContracts.length}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Comments',
|
title: 'Comments',
|
||||||
content: (
|
content: <UserCommentsList user={user} />,
|
||||||
<UserCommentsList
|
|
||||||
user={user}
|
|
||||||
contractsById={contractsById}
|
|
||||||
comments={usersComments}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
tabIcon: (
|
|
||||||
<span className="px-0.5 font-bold">
|
|
||||||
{usersComments.length}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Bets',
|
title: 'Bets',
|
||||||
content: (
|
content: (
|
||||||
<div>
|
<>
|
||||||
<PortfolioValueSection
|
<PortfolioValueSection userId={user.id} />
|
||||||
portfolioHistory={portfolioHistory}
|
<BetsList user={user} />
|
||||||
/>
|
</>
|
||||||
<BetsList
|
|
||||||
user={user}
|
|
||||||
bets={userBets}
|
|
||||||
hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022}
|
|
||||||
contractsById={contractsById}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
tabIcon: <span className="px-0.5 font-bold">{betCount}</span>,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<LoadingIndicator />
|
|
||||||
)}
|
|
||||||
</Col>
|
</Col>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
|
|
24
web/components/visibility-observer.tsx
Normal file
24
web/components/visibility-observer.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useEvent } from '../hooks/use-event'
|
||||||
|
|
||||||
|
export function VisibilityObserver(props: {
|
||||||
|
className?: string
|
||||||
|
onVisibilityUpdated: (visible: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { className } = props
|
||||||
|
const [elem, setElem] = useState<HTMLElement | null>(null)
|
||||||
|
const onVisibilityUpdated = useEvent(props.onVisibilityUpdated)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hasIOSupport = !!window.IntersectionObserver
|
||||||
|
if (!hasIOSupport || !elem) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
onVisibilityUpdated(entry.isIntersecting)
|
||||||
|
}, {})
|
||||||
|
observer.observe(elem)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [elem, onVisibilityUpdated])
|
||||||
|
|
||||||
|
return <div ref={setElem} className={className}></div>
|
||||||
|
}
|
|
@ -1,8 +1,7 @@
|
||||||
import { isAdmin } from 'common/envs/constants'
|
import { isAdmin } from 'common/envs/constants'
|
||||||
import { usePrivateUser, useUser } from './use-user'
|
import { usePrivateUser } from './use-user'
|
||||||
|
|
||||||
export const useAdmin = () => {
|
export const useAdmin = () => {
|
||||||
const user = useUser()
|
const privateUser = usePrivateUser()
|
||||||
const privateUser = usePrivateUser(user?.id)
|
|
||||||
return isAdmin(privateUser?.email || '')
|
return isAdmin(privateUser?.email || '')
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,21 +14,22 @@ export const useBets = (
|
||||||
options?: { filterChallenges: boolean; filterRedemptions: boolean }
|
options?: { filterChallenges: boolean; filterRedemptions: boolean }
|
||||||
) => {
|
) => {
|
||||||
const [bets, setBets] = useState<Bet[] | undefined>()
|
const [bets, setBets] = useState<Bet[] | undefined>()
|
||||||
|
const filterChallenges = !!options?.filterChallenges
|
||||||
|
const filterRedemptions = !!options?.filterRedemptions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contractId)
|
if (contractId)
|
||||||
return listenForBets(contractId, (bets) => {
|
return listenForBets(contractId, (bets) => {
|
||||||
if (options)
|
if (filterChallenges || filterRedemptions)
|
||||||
setBets(
|
setBets(
|
||||||
bets.filter(
|
bets.filter(
|
||||||
(bet) =>
|
(bet) =>
|
||||||
(options.filterChallenges ? !bet.challengeSlug : true) &&
|
(filterChallenges ? !bet.challengeSlug : true) &&
|
||||||
(options.filterRedemptions ? !bet.isRedemption : true)
|
(filterRedemptions ? !bet.isRedemption : true)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else setBets(bets)
|
else setBets(bets)
|
||||||
})
|
})
|
||||||
}, [contractId, options])
|
}, [contractId, filterChallenges, filterRedemptions])
|
||||||
|
|
||||||
return bets
|
return bets
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
export function useIsVisible(element: HTMLElement | null) {
|
|
||||||
return !!useIntersectionObserver(element)?.isIntersecting
|
|
||||||
}
|
|
||||||
|
|
||||||
function useIntersectionObserver(
|
|
||||||
elem: HTMLElement | null
|
|
||||||
): IntersectionObserverEntry | undefined {
|
|
||||||
const [entry, setEntry] = useState<IntersectionObserverEntry>()
|
|
||||||
|
|
||||||
const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
|
|
||||||
setEntry(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hasIOSupport = !!window.IntersectionObserver
|
|
||||||
|
|
||||||
if (!hasIOSupport || !elem) return
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(updateEntry, {})
|
|
||||||
observer.observe(elem)
|
|
||||||
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [elem])
|
|
||||||
|
|
||||||
return entry
|
|
||||||
}
|
|
|
@ -1,47 +0,0 @@
|
||||||
import { mapValues } from 'lodash'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { Contract } from 'common/contract'
|
|
||||||
import { trackView } from 'web/lib/firebase/tracking'
|
|
||||||
import { useIsVisible } from './use-is-visible'
|
|
||||||
import { useUser } from './use-user'
|
|
||||||
|
|
||||||
export const useSeenContracts = () => {
|
|
||||||
const [seenContracts, setSeenContracts] = useState<{
|
|
||||||
[contractId: string]: number
|
|
||||||
}>({})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSeenContracts(getSeenContracts())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return seenContracts
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSaveSeenContract = (
|
|
||||||
elem: HTMLElement | null,
|
|
||||||
contract: Contract
|
|
||||||
) => {
|
|
||||||
const isVisible = useIsVisible(elem)
|
|
||||||
const user = useUser()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isVisible && user) {
|
|
||||||
const newSeenContracts = {
|
|
||||||
...getSeenContracts(),
|
|
||||||
[contract.id]: Date.now(),
|
|
||||||
}
|
|
||||||
localStorage.setItem(key, JSON.stringify(newSeenContracts))
|
|
||||||
|
|
||||||
trackView(user.id, contract.id)
|
|
||||||
}
|
|
||||||
}, [isVisible, user, contract])
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = 'feed-seen-contracts'
|
|
||||||
|
|
||||||
const getSeenContracts = () => {
|
|
||||||
return mapValues(
|
|
||||||
JSON.parse(localStorage.getItem(key) ?? '{}'),
|
|
||||||
(time) => +time
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,9 +1,5 @@
|
||||||
import { defaults, debounce } from 'lodash'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/router'
|
import { NextRouter, useRouter } from 'next/router'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { DEFAULT_SORT } from 'web/components/contract-search'
|
|
||||||
|
|
||||||
const MARKETS_SORT = 'markets_sort'
|
|
||||||
|
|
||||||
export type Sort =
|
export type Sort =
|
||||||
| 'newest'
|
| 'newest'
|
||||||
|
@ -15,128 +11,55 @@ export type Sort =
|
||||||
| 'last-updated'
|
| 'last-updated'
|
||||||
| 'score'
|
| 'score'
|
||||||
|
|
||||||
export function getSavedSort() {
|
type UpdatedQueryParams = { [k: string]: string }
|
||||||
// TODO: this obviously doesn't work with SSR, common sense would suggest
|
type QuerySortOpts = { useUrl: boolean }
|
||||||
// that we should save things like this in cookies so the server has them
|
|
||||||
if (typeof window !== 'undefined') {
|
function withURLParams(location: Location, params: UpdatedQueryParams) {
|
||||||
return localStorage.getItem(MARKETS_SORT) as Sort | null
|
const newParams = new URLSearchParams(location.search)
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (!v) {
|
||||||
|
newParams.delete(k)
|
||||||
} else {
|
} else {
|
||||||
return null
|
newParams.set(k, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const newUrl = new URL(location.href)
|
||||||
|
newUrl.search = newParams.toString()
|
||||||
|
return newUrl
|
||||||
|
}
|
||||||
|
|
||||||
export function useInitialQueryAndSort(options?: {
|
function updateURL(params: UpdatedQueryParams) {
|
||||||
defaultSort: Sort
|
// see relevant discussion here https://github.com/vercel/next.js/discussions/18072
|
||||||
shouldLoadFromStorage?: boolean
|
const url = withURLParams(window.location, params).toString()
|
||||||
}) {
|
const updatedState = { ...window.history.state, as: url, url }
|
||||||
const { defaultSort, shouldLoadFromStorage } = defaults(options, {
|
window.history.replaceState(updatedState, '', url)
|
||||||
defaultSort: DEFAULT_SORT,
|
}
|
||||||
shouldLoadFromStorage: true,
|
|
||||||
})
|
function getStringURLParam(router: NextRouter, k: string) {
|
||||||
|
const v = router.query[k]
|
||||||
|
return typeof v === 'string' ? v : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQuery(defaultQuery: string, opts?: QuerySortOpts) {
|
||||||
|
const useUrl = opts?.useUrl ?? false
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const initialQuery = useUrl ? getStringURLParam(router, 'q') : null
|
||||||
const [initialSort, setInitialSort] = useState<Sort | undefined>(undefined)
|
const [query, setQuery] = useState(initialQuery ?? defaultQuery)
|
||||||
const [initialQuery, setInitialQuery] = useState('')
|
if (!useUrl) {
|
||||||
|
return [query, setQuery] as const
|
||||||
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 {
|
} else {
|
||||||
setInitialSort(sort ?? defaultSort)
|
return [query, (q: string) => (setQuery(q), updateURL({ q }))] as const
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [defaultSort, router.isReady, shouldLoadFromStorage])
|
|
||||||
|
|
||||||
return {
|
|
||||||
initialSort,
|
|
||||||
initialQuery,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useQueryAndSortParams(options?: {
|
export function useSort(defaultSort: Sort, opts?: QuerySortOpts) {
|
||||||
defaultSort?: Sort
|
const useUrl = opts?.useUrl ?? false
|
||||||
shouldLoadFromStorage?: boolean
|
|
||||||
}) {
|
|
||||||
const { defaultSort = DEFAULT_SORT, shouldLoadFromStorage = true } =
|
|
||||||
options ?? {}
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const initialSort = useUrl ? (getStringURLParam(router, 's') as Sort) : null
|
||||||
const { s: sort, q: query } = router.query as {
|
const [sort, setSort] = useState(initialSort ?? defaultSort)
|
||||||
q?: string
|
if (!useUrl) {
|
||||||
s?: Sort
|
return [sort, setSort] as const
|
||||||
}
|
} else {
|
||||||
|
return [sort, (s: Sort) => (setSort(s), updateURL({ s }))] as const
|
||||||
const setSort = (sort: Sort | undefined) => {
|
|
||||||
router.replace({ query: { ...router.query, s: sort } }, undefined, {
|
|
||||||
shallow: true,
|
|
||||||
})
|
|
||||||
if (shouldLoadFromStorage) {
|
|
||||||
localStorage.setItem(MARKETS_SORT, sort || '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [queryState, setQueryState] = useState(query)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setQueryState(query)
|
|
||||||
}, [query])
|
|
||||||
|
|
||||||
// Debounce router query update.
|
|
||||||
const pushQuery = useMemo(
|
|
||||||
() =>
|
|
||||||
debounce((query: string | undefined) => {
|
|
||||||
const queryObj = { ...router.query, q: query }
|
|
||||||
if (!query) delete queryObj.q
|
|
||||||
router.replace({ query: queryObj }, undefined, {
|
|
||||||
shallow: true,
|
|
||||||
})
|
|
||||||
}, 100),
|
|
||||||
[router]
|
|
||||||
)
|
|
||||||
|
|
||||||
const setQuery = (query: string | undefined) => {
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { isEqual } from 'lodash'
|
import { isEqual } from 'lodash'
|
||||||
import { useMemo, useRef, useState } from 'react'
|
import { SetStateAction, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
export const useStateCheckEquality = <T>(initialState: T) => {
|
export const useStateCheckEquality = <T>(initialState: T) => {
|
||||||
const [state, setState] = useState(initialState)
|
const [state, setState] = useState(initialState)
|
||||||
|
@ -8,8 +8,9 @@ export const useStateCheckEquality = <T>(initialState: T) => {
|
||||||
stateRef.current = state
|
stateRef.current = state
|
||||||
|
|
||||||
const checkSetState = useMemo(
|
const checkSetState = useMemo(
|
||||||
() => (newState: T) => {
|
() => (next: SetStateAction<T>) => {
|
||||||
const state = stateRef.current
|
const state = stateRef.current
|
||||||
|
const newState = next instanceof Function ? next(state) : next
|
||||||
if (!isEqual(state, newState)) {
|
if (!isEqual(state, newState)) {
|
||||||
setState(newState)
|
setState(newState)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,31 +1,19 @@
|
||||||
import { useContext, useEffect, useState } from 'react'
|
import { useContext } from 'react'
|
||||||
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
||||||
import { QueryClient } from 'react-query'
|
import { QueryClient } from 'react-query'
|
||||||
|
|
||||||
import { doc, DocumentData, where } from 'firebase/firestore'
|
import { doc, DocumentData } from 'firebase/firestore'
|
||||||
import { PrivateUser } from 'common/user'
|
import { getUser, User, users } from 'web/lib/firebase/users'
|
||||||
import {
|
|
||||||
getUser,
|
|
||||||
listenForPrivateUser,
|
|
||||||
User,
|
|
||||||
users,
|
|
||||||
} from 'web/lib/firebase/users'
|
|
||||||
import { AuthContext } from 'web/components/auth-context'
|
import { AuthContext } from 'web/components/auth-context'
|
||||||
|
|
||||||
export const useUser = () => {
|
export const useUser = () => {
|
||||||
return useContext(AuthContext)
|
const authUser = useContext(AuthContext)
|
||||||
|
return authUser ? authUser.user : authUser
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePrivateUser = (userId?: string) => {
|
export const usePrivateUser = () => {
|
||||||
const [privateUser, setPrivateUser] = useState<
|
const authUser = useContext(AuthContext)
|
||||||
PrivateUser | null | undefined
|
return authUser ? authUser.privateUser : authUser
|
||||||
>(undefined)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (userId) return listenForPrivateUser(userId, setPrivateUser)
|
|
||||||
}, [userId])
|
|
||||||
|
|
||||||
return privateUser
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUserById = (userId = '_') => {
|
export const useUserById = (userId = '_') => {
|
||||||
|
|
|
@ -2,53 +2,73 @@ import { PROJECT_ID } from 'common/envs/constants'
|
||||||
import { setCookie, getCookies } from '../util/cookie'
|
import { setCookie, getCookies } from '../util/cookie'
|
||||||
import { IncomingMessage, ServerResponse } from 'http'
|
import { IncomingMessage, ServerResponse } from 'http'
|
||||||
|
|
||||||
const TOKEN_KINDS = ['refresh', 'id'] as const
|
const ONE_HOUR_SECS = 60 * 60
|
||||||
type TokenKind = typeof TOKEN_KINDS[number]
|
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 getAuthCookieName = (kind: TokenKind) => {
|
||||||
const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replace(/-/g, '_')
|
const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replace(/-/g, '_')
|
||||||
return `FIREBASE_TOKEN_${suffix}`
|
return `FIREBASE_TOKEN_${suffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const ID_COOKIE_NAME = getAuthCookieName('id')
|
const COOKIE_NAMES = Object.fromEntries(
|
||||||
const REFRESH_COOKIE_NAME = getAuthCookieName('refresh')
|
TOKEN_KINDS.map((k) => [k, getAuthCookieName(k)])
|
||||||
|
) as Record<TokenKind, string>
|
||||||
|
|
||||||
export const getAuthCookies = (request?: IncomingMessage) => {
|
const getCookieDataIsomorphic = (req?: IncomingMessage) => {
|
||||||
const data = request != null ? request.headers.cookie ?? '' : document.cookie
|
if (req != null) {
|
||||||
const cookies = getCookies(data)
|
return req.headers.cookie ?? ''
|
||||||
return {
|
} else if (document != null) {
|
||||||
idToken: cookies[ID_COOKIE_NAME] as string | undefined,
|
return document.cookie
|
||||||
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])
|
|
||||||
} else {
|
} else {
|
||||||
document.cookie = idCookie
|
throw new Error(
|
||||||
document.cookie = refreshCookie
|
'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)
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { User } from 'common/user'
|
||||||
import { Comment } from 'common/comment'
|
import { Comment } from 'common/comment'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
import { track } from '@amplitude/analytics-browser'
|
import { track } from '@amplitude/analytics-browser'
|
||||||
|
import { JSONContent } from '@tiptap/react'
|
||||||
|
|
||||||
export type { Comment }
|
export type { Comment }
|
||||||
|
|
||||||
|
@ -21,7 +22,7 @@ export const MAX_COMMENT_LENGTH = 10000
|
||||||
|
|
||||||
export async function createCommentOnContract(
|
export async function createCommentOnContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
text: string,
|
content: JSONContent,
|
||||||
commenter: User,
|
commenter: User,
|
||||||
betId?: string,
|
betId?: string,
|
||||||
answerOutcome?: string,
|
answerOutcome?: string,
|
||||||
|
@ -34,7 +35,7 @@ export async function createCommentOnContract(
|
||||||
id: ref.id,
|
id: ref.id,
|
||||||
contractId,
|
contractId,
|
||||||
userId: commenter.id,
|
userId: commenter.id,
|
||||||
text: text.slice(0, MAX_COMMENT_LENGTH),
|
content: content,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
userName: commenter.name,
|
userName: commenter.name,
|
||||||
userUsername: commenter.username,
|
userUsername: commenter.username,
|
||||||
|
@ -53,7 +54,7 @@ export async function createCommentOnContract(
|
||||||
}
|
}
|
||||||
export async function createCommentOnGroup(
|
export async function createCommentOnGroup(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
text: string,
|
content: JSONContent,
|
||||||
user: User,
|
user: User,
|
||||||
replyToCommentId?: string
|
replyToCommentId?: string
|
||||||
) {
|
) {
|
||||||
|
@ -62,7 +63,7 @@ export async function createCommentOnGroup(
|
||||||
id: ref.id,
|
id: ref.id,
|
||||||
groupId,
|
groupId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
text: text.slice(0, MAX_COMMENT_LENGTH),
|
content: content,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
userName: user.name,
|
userName: user.name,
|
||||||
userUsername: user.username,
|
userUsername: user.username,
|
||||||
|
|
|
@ -266,12 +266,16 @@ export function listenForHotContracts(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHotContracts() {
|
const trendingContractsQuery = query(
|
||||||
const data = await getValues<Contract>(hotContractsQuery)
|
contracts,
|
||||||
return sortBy(
|
where('isResolved', '==', false),
|
||||||
chooseRandomSubset(data, 10),
|
where('visibility', '==', 'public'),
|
||||||
(contract) => -1 * contract.volume24Hours
|
orderBy('popularityScore', 'desc'),
|
||||||
|
limit(10)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export async function getTrendingContracts() {
|
||||||
|
return await getValues<Contract>(trendingContractsQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContractsBySlugs(slugs: string[]) {
|
export async function getContractsBySlugs(slugs: string[]) {
|
||||||
|
|
|
@ -1,9 +1,25 @@
|
||||||
import * as admin from 'firebase-admin'
|
|
||||||
import fetch from 'node-fetch'
|
import fetch from 'node-fetch'
|
||||||
import { IncomingMessage, ServerResponse } from 'http'
|
import { IncomingMessage, ServerResponse } from 'http'
|
||||||
import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants'
|
import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants'
|
||||||
import { getAuthCookies, setAuthCookies } from './auth'
|
import { getFunctionUrl } from 'common/api'
|
||||||
import { GetServerSideProps, GetServerSidePropsContext } from 'next'
|
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 () => {
|
const ensureApp = async () => {
|
||||||
// Note: firebase-admin can only be imported from a server context,
|
// Note: firebase-admin can only be imported from a server context,
|
||||||
|
@ -33,7 +49,21 @@ const requestFirebaseIdToken = async (refreshToken: string) => {
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new Error(`Could not refresh ID token: ${await result.text()}`)
|
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 = {
|
type RequestContext = {
|
||||||
|
@ -41,53 +71,143 @@ type RequestContext = {
|
||||||
res: ServerResponse
|
res: ServerResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerAuthenticatedUid = async (ctx: RequestContext) => {
|
const authAndRefreshTokens = async (ctx: RequestContext) => {
|
||||||
const app = await ensureApp()
|
const adminAuth = (await ensureApp()).auth()
|
||||||
const auth = app.auth()
|
const clientAuth = getAuth(clientApp)
|
||||||
const { idToken, refreshToken } = getAuthCookies(ctx.req)
|
console.debug('Initialized Firebase auth libraries.')
|
||||||
|
|
||||||
// If we have a valid ID token, verify the user immediately with no network trips.
|
let { id, refresh, custom } = getTokensFromCookies(ctx.req)
|
||||||
// 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.
|
// step 0: if you have no refresh token you are logged out
|
||||||
if (idToken != null) {
|
if (refresh == null) {
|
||||||
|
console.debug('User is unauthenticated.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug('User may be authenticated; checking cookies.')
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
return (await auth.verifyIdToken(idToken))?.uid
|
await adminAuth.verifyIdToken(id)
|
||||||
|
console.debug('Verified ID token.')
|
||||||
} catch {
|
} catch {
|
||||||
// plausibly expired; try the refresh token, if it's present
|
id = undefined
|
||||||
|
console.debug('Invalid existing ID token.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (refreshToken != null) {
|
if (id == null) {
|
||||||
|
// ask for a new one from google using the refresh token
|
||||||
try {
|
try {
|
||||||
const resp = await requestFirebaseIdToken(refreshToken)
|
const resp = await requestFirebaseIdToken(refresh)
|
||||||
setAuthCookies(resp.id_token, resp.refresh_token, ctx.res)
|
console.debug('Obtained fresh ID token from Firebase.')
|
||||||
return (await auth.verifyIdToken(resp.id_token))?.uid
|
id = resp.id_token
|
||||||
|
refresh = resp.refresh_token
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// this is a big unexpected problem -- either their cookies are corrupt
|
// big unexpected problem -- functionally, they are not logged in
|
||||||
// or the refresh token API is down. functionally, they are not logged in
|
console.error(e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
const creds = await signInWithCustomToken(clientAuth, custom)
|
||||||
|
console.debug('Signed in with custom token.')
|
||||||
|
return { creds, id, refresh, custom }
|
||||||
|
} catch {
|
||||||
|
custom = undefined
|
||||||
|
console.debug('Invalid existing custom token.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
console.debug('Obtained fresh custom token from backend.')
|
||||||
|
custom = resp.token
|
||||||
|
const creds = await signInWithCustomToken(clientAuth, custom)
|
||||||
|
console.debug('Signed in with custom token.')
|
||||||
|
return { creds, id, refresh, custom }
|
||||||
|
} catch (e) {
|
||||||
|
// big unexpected problem -- functionally, they are not logged in
|
||||||
|
console.error(e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authenticateOnServer = async (ctx: RequestContext) => {
|
||||||
|
console.debug('Server authentication sequence starting.')
|
||||||
|
const tokens = await authAndRefreshTokens(ctx)
|
||||||
|
console.debug('Finished checking and refreshing tokens.')
|
||||||
|
const creds = tokens?.creds
|
||||||
|
try {
|
||||||
|
if (tokens == null) {
|
||||||
|
deleteTokenCookies(ctx.res)
|
||||||
|
console.debug('Not logged in; cleared token cookies.')
|
||||||
|
} else {
|
||||||
|
setTokenCookies(tokens, ctx.res)
|
||||||
|
console.debug('Logged in; set current token cookies.')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// definitely not supposed to happen, but let's be maximally robust
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
}
|
return creds ?? null
|
||||||
return undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => {
|
// 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) => {
|
return async (ctx: GetServerSidePropsContext) => {
|
||||||
const uid = await getServerAuthenticatedUid(ctx)
|
const creds = await authenticateOnServer(ctx)
|
||||||
if (uid == null) {
|
if (creds == null) {
|
||||||
return fn != null ? await fn(ctx) : { props: {} }
|
if (fn == null) {
|
||||||
|
return { props: {} }
|
||||||
} else {
|
} else {
|
||||||
|
const props = await fn(ctx)
|
||||||
|
console.debug('Finished getting initial props for rendering.')
|
||||||
|
return props
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.debug(`Redirecting to ${dest}.`)
|
||||||
return { redirect: { destination: dest, permanent: false } }
|
return { redirect: { destination: dest, permanent: false } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const redirectIfLoggedOut = (dest: string, fn?: GetServerSideProps) => {
|
export const redirectIfLoggedOut = <P>(
|
||||||
|
dest: string,
|
||||||
|
fn?: GetServerSidePropsAuthed<P>
|
||||||
|
) => {
|
||||||
return async (ctx: GetServerSidePropsContext) => {
|
return async (ctx: GetServerSidePropsContext) => {
|
||||||
const uid = await getServerAuthenticatedUid(ctx)
|
const creds = await authenticateOnServer(ctx)
|
||||||
if (uid == null) {
|
if (creds == null) {
|
||||||
|
console.debug(`Redirecting to ${dest}.`)
|
||||||
return { redirect: { destination: dest, permanent: false } }
|
return { redirect: { destination: dest, permanent: false } }
|
||||||
} else {
|
} else {
|
||||||
return fn != null ? await fn(ctx) : { props: {} }
|
if (fn == null) {
|
||||||
|
return { props: {} }
|
||||||
|
} else {
|
||||||
|
const props = await fn(ctx, creds)
|
||||||
|
console.debug('Finished getting initial props for rendering.')
|
||||||
|
return props
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user