This commit is contained in:
marsteralex 2022-08-17 18:59:27 -04:00
commit f7a32a0913
151 changed files with 6051 additions and 3650 deletions

43
.github/workflows/format.yml vendored Normal file
View 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 }}

View File

@ -14,18 +14,21 @@ export type Bet = {
probBefore: number
probAfter: number
sale?: {
amount: number // amount user makes from sale
betId: string // id of bet being sold
// TODO: add sale time?
}
fees: Fees
isSold?: boolean // true if this BUY bet has been sold
isAnte?: boolean
isLiquidityProvision?: boolean
isRedemption?: boolean
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>
export type NumericBet = Bet & {

View File

@ -1,4 +1,4 @@
import { maxBy } from 'lodash'
import { maxBy, sortBy, sum, sumBy } from 'lodash'
import { Bet, LimitBet } from './bet'
import {
calculateCpmmSale,
@ -133,10 +133,46 @@ export function resolvedPayout(contract: Contract, bet: Bet) {
: 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[]) {
const { resolution } = contract
const isCpmm = contract.mechanism === 'cpmm-1'
let currentInvested = 0
let totalInvested = 0
let payout = 0
let loan = 0
@ -162,7 +198,6 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
saleValue -= amount
}
currentInvested += amount
loan += loanAmount ?? 0
payout += resolution
? calculatePayout(contract, bet, resolution)
@ -174,12 +209,13 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
const profit = payout + saleValue + redeemed - totalInvested
const profitPercent = (profit / totalInvested) * 100
const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets)
const hasShares = Object.values(totalShares).some(
(shares) => !floatingEqual(shares, 0)
)
return {
invested: Math.max(0, currentInvested),
invested,
payout,
netPayout,
profit,

65
common/challenge.ts Normal file
View File

@ -0,0 +1,65 @@
import { IS_PRIVATE_MANIFOLD } from './envs/constants'
export type Challenge = {
// The link to send: https://manifold.markets/challenges/username/market-slug/{slug}
// Also functions as the unique id for the link.
slug: string
// The user that created the challenge.
creatorId: string
creatorUsername: string
creatorName: string
creatorAvatarUrl?: string
// Displayed to people claiming the challenge
message: string
// How much to put up
creatorAmount: number
// YES or NO for now
creatorOutcome: string
// Different than the creator
acceptorOutcome: string
acceptorAmount: number
// The probability the challenger thinks
creatorOutcomeProb: number
contractId: string
contractSlug: string
contractQuestion: string
contractCreatorUsername: string
createdTime: number
// If null, the link is valid forever
expiresTime: number | null
// How many times the challenge can be used
maxUses: number
// Used for simpler caching
acceptedByUserIds: string[]
// Successful redemptions of the link
acceptances: Acceptance[]
// TODO: will have to fill this on resolve contract
isResolved: boolean
resolutionOutcome?: string
}
export type Acceptance = {
// User that accepted the challenge
userId: string
userUsername: string
userName: string
userAvatarUrl: string
// The ID of the successful bet that tracks the money moved
betId: string
createdTime: number
}
export const CHALLENGES_ENABLED = !IS_PRIVATE_MANIFOLD

View File

@ -1,3 +1,5 @@
import type { JSONContent } from '@tiptap/core'
// Currently, comments are created after the bet, not atomically with the bet.
// They're uniquely identified by the pair contractId/betId.
export type Comment = {
@ -9,11 +11,15 @@ export type Comment = {
replyToCommentId?: string
userId: string
text: string
/** @deprecated - content now stored as JSON in content*/
text?: string
content: JSONContent
createdTime: number
// Denormalized, for rendering comments
userName: string
userUsername: string
userAvatarUrl?: string
contractSlug?: string
contractQuestion?: string
}

View File

@ -139,7 +139,7 @@ export const OUTCOME_TYPES = [
] as const
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 CPMM_MIN_POOL_QTY = 0.01

View File

@ -25,6 +25,10 @@ export function isAdmin(email: string) {
return ENV_CONFIG.adminEmails.includes(email)
}
export function isManifoldId(userId: string) {
return userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2'
}
export const DOMAIN = ENV_CONFIG.domain
export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId

View File

@ -2,6 +2,7 @@ import { EnvConfig, PROD_CONFIG } from './prod'
export const DEV_CONFIG: EnvConfig = {
...PROD_CONFIG,
domain: 'dev.manifold.markets',
firebaseConfig: {
apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw',
authDomain: 'dev-mantic-markets.firebaseapp.com',

View File

@ -37,6 +37,7 @@ export type notification_source_types =
| 'group'
| 'user'
| 'bonus'
| 'challenge'
export type notification_source_update_types =
| 'created'
@ -64,3 +65,4 @@ export type notification_reason_types =
| 'tip_received'
| 'bet_fill'
| 'user_joined_from_your_group_invite'
| 'challenge_accepted'

View File

@ -8,6 +8,7 @@
},
"sideEffects": false,
"dependencies": {
"@tiptap/core": "2.0.0-beta.181",
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",

View File

@ -1,3 +1,40 @@
import { isEqual } from 'lodash'
export function filterDefined<T>(array: (T | null | undefined)[]) {
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
}

View File

@ -22,6 +22,8 @@ import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type'
import { uniq } from 'lodash'
export function parseTags(text: string) {
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
@ -61,6 +63,15 @@ const checkAgainstQuery = (query: string, corpus: string) =>
export const searchInAny = (query: string, ...fields: string[]) =>
fields.some((field) => checkAgainstQuery(query, field))
/** @return user ids of all \@mentions */
export function parseMentions(data: JSONContent): string[] {
const mentions = data.content?.flatMap(parseMentions) ?? [] //dfs
if (data.type === 'mention' && data.attrs) {
mentions.push(data.attrs.id as string)
}
return uniq(mentions)
}
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
export const exhibitExts = [
Blockquote,
@ -84,6 +95,7 @@ export const exhibitExts = [
Link,
Mention,
Iframe,
TiptapTweet,
]
export function richTextToString(text?: JSONContent) {

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

View File

@ -135,7 +135,8 @@ Requires no authorization.
// Market attributes. All times are in milliseconds since epoch
closeTime?: number // Min of creator's chosen date, and resolutionTime
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.
// This list also includes the predefined categories shown as filters on the home page.
@ -162,6 +163,8 @@ Requires no authorization.
resolutionTime?: number
resolution?: string
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":"{...}"}'
```
### `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`
Creates a new market on behalf of the authorized user.
@ -537,6 +544,7 @@ Parameters:
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`.
- `question`: Required. The headline question 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.
- `tags`: Optional. An array of string tags for the market.

View File

@ -30,7 +30,8 @@
},
"devDependencies": {
"@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": {
"production": [

View File

@ -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",
"fieldPath": "createdTime",

View File

@ -20,17 +20,17 @@ service cloud.firestore {
match /users/{userId} {
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()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']);
// 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()
.hasOnly(['referredByUserId', 'referredByContractId', 'referredByGroupId'])
// only one referral allowed per user
&& !("referredByUserId" in resource.data)
// 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:
// && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id);
}
@ -39,6 +39,17 @@ service cloud.firestore {
allow read;
}
match /{somePath=**}/challenges/{challengeId}{
allow read;
}
match /contracts/{contractId}/challenges/{challengeId}{
allow read;
allow create: if request.auth.uid == request.resource.data.creatorId;
// allow update if there have been no claims yet and if the challenge is still open
allow update: if request.auth.uid == resource.data.creatorId;
}
match /users/{userId}/follows/{followUserId} {
allow read;
allow write: if request.auth.uid == userId;
@ -49,8 +60,8 @@ service cloud.firestore {
}
match /private-users/{userId} {
allow read: if resource.data.id == request.auth.uid || isAdmin();
allow update: if (resource.data.id == request.auth.uid || isAdmin())
allow read: if userId == request.auth.uid || isAdmin();
allow update: if (userId == request.auth.uid || isAdmin())
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences' ]);
}

View File

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

View File

@ -0,0 +1,167 @@
import { z } from 'zod'
import { APIError, newEndpoint, validate } from './api'
import { log } from './utils'
import { Contract, CPMMBinaryContract } from '../../common/contract'
import { User } from '../../common/user'
import * as admin from 'firebase-admin'
import { FieldValue } from 'firebase-admin/firestore'
import { removeUndefinedProps } from '../../common/util/object'
import { Acceptance, Challenge } from '../../common/challenge'
import { CandidateBet } from '../../common/new-bet'
import { createChallengeAcceptedNotification } from './create-notification'
import { noFees } from '../../common/fees'
import { formatMoney, formatPercent } from '../../common/util/format'
const bodySchema = z.object({
contractId: z.string(),
challengeSlug: z.string(),
outcomeType: z.literal('BINARY'),
closeTime: z.number().gte(Date.now()),
})
const firestore = admin.firestore()
export const acceptchallenge = newEndpoint({}, async (req, auth) => {
const { challengeSlug, contractId } = validate(bodySchema, req.body)
const result = await firestore.runTransaction(async (trans) => {
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const challengeDoc = firestore.doc(
`contracts/${contractId}/challenges/${challengeSlug}`
)
const [contractSnap, userSnap, challengeSnap] = await trans.getAll(
contractDoc,
userDoc,
challengeDoc
)
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.')
if (!challengeSnap.exists) throw new APIError(400, 'Challenge not found.')
const anyContract = contractSnap.data() as Contract
const user = userSnap.data() as User
const challenge = challengeSnap.data() as Challenge
if (challenge.acceptances.length > 0)
throw new APIError(400, 'Challenge already accepted.')
const creatorDoc = firestore.doc(`users/${challenge.creatorId}`)
const creatorSnap = await trans.get(creatorDoc)
if (!creatorSnap.exists) throw new APIError(400, 'Creator not found.')
const creator = creatorSnap.data() as User
const {
creatorAmount,
acceptorOutcome,
creatorOutcome,
creatorOutcomeProb,
acceptorAmount,
} = challenge
if (user.balance < acceptorAmount)
throw new APIError(400, 'Insufficient balance.')
if (creator.balance < creatorAmount)
throw new APIError(400, 'Creator has insufficient balance.')
const contract = anyContract as CPMMBinaryContract
const shares = (1 / creatorOutcomeProb) * creatorAmount
const createdTime = Date.now()
const probOfYes =
creatorOutcome === 'YES' ? creatorOutcomeProb : 1 - creatorOutcomeProb
log(
'Creating challenge bet for',
user.username,
shares,
acceptorOutcome,
'shares',
'at',
formatPercent(creatorOutcomeProb),
'for',
formatMoney(acceptorAmount)
)
const yourNewBet: CandidateBet = removeUndefinedProps({
orderAmount: acceptorAmount,
amount: acceptorAmount,
shares,
isCancelled: false,
contractId: contract.id,
outcome: acceptorOutcome,
probBefore: probOfYes,
probAfter: probOfYes,
loanAmount: 0,
createdTime,
fees: noFees,
challengeSlug: challenge.slug,
})
const yourNewBetDoc = contractDoc.collection('bets').doc()
trans.create(yourNewBetDoc, {
id: yourNewBetDoc.id,
userId: user.id,
...yourNewBet,
})
trans.update(userDoc, { balance: FieldValue.increment(-yourNewBet.amount) })
const creatorNewBet: CandidateBet = removeUndefinedProps({
orderAmount: creatorAmount,
amount: creatorAmount,
shares,
isCancelled: false,
contractId: contract.id,
outcome: creatorOutcome,
probBefore: probOfYes,
probAfter: probOfYes,
loanAmount: 0,
createdTime,
fees: noFees,
challengeSlug: challenge.slug,
})
const creatorBetDoc = contractDoc.collection('bets').doc()
trans.create(creatorBetDoc, {
id: creatorBetDoc.id,
userId: creator.id,
...creatorNewBet,
})
trans.update(creatorDoc, {
balance: FieldValue.increment(-creatorNewBet.amount),
})
const volume = contract.volume + yourNewBet.amount + creatorNewBet.amount
trans.update(contractDoc, { volume })
trans.update(
challengeDoc,
removeUndefinedProps({
acceptedByUserIds: [user.id],
acceptances: [
{
userId: user.id,
betId: yourNewBetDoc.id,
createdTime,
amount: acceptorAmount,
userUsername: user.username,
userName: user.name,
userAvatarUrl: user.avatarUrl,
} as Acceptance,
],
})
)
await createChallengeAcceptedNotification(
user,
creator,
challenge,
acceptorAmount,
contract
)
log('Done, sent notification.')
return yourNewBetDoc
})
return { betId: result.id }
})

View File

@ -3,7 +3,7 @@ import * as Amplitude from '@amplitude/node'
import { DEV_CONFIG } from '../../common/envs/dev'
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
@ -15,10 +15,12 @@ export const track = async (
eventProperties?: any,
amplitudeProperties?: Partial<Amplitude.Event>
) => {
await amp.logEvent({
event_type: eventName,
user_id: userId,
event_properties: eventProperties,
...amplitudeProperties,
})
return await tryOrLogError(
amp.logEvent({
event_type: eventName,
user_id: userId,
event_properties: eventProperties,
...amplitudeProperties,
})
)
}

View File

@ -78,6 +78,19 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
}
}
export const writeResponseError = (e: unknown, res: Response) => {
if (e instanceof APIError) {
const output: { [k: string]: unknown } = { message: e.message }
if (e.details != null) {
output.details = e.details
}
res.status(e.code).json(output)
} else {
error(e)
res.status(500).json({ message: 'An unknown error occurred.' })
}
}
export const zTimestamp = () => {
return z.preprocess((arg) => {
return typeof arg == 'number' ? new Date(arg) : undefined
@ -131,16 +144,7 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
const authedUser = await lookupUser(await parseCredentials(req))
res.status(200).json(await fn(req, authedUser))
} catch (e) {
if (e instanceof APIError) {
const output: { [k: string]: unknown } = { message: e.message }
if (e.details != null) {
output.details = e.details
}
res.status(e.code).json(output)
} else {
error(e)
res.status(500).json({ message: 'An unknown error occurred.' })
}
writeResponseError(e, res)
}
},
} as EndpointDefinition

View File

@ -59,7 +59,7 @@ const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
const bodySchema = z.object({
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(),
closeTime: zTimestamp().refine(
(date) => date.getTime() > new Date().getTime(),
@ -133,41 +133,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
if (ante > user.balance)
throw new APIError(400, `Balance must be at least ${ante}.`)
const slug = await getSlug(question)
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
let group: Group | null = null
if (groupId) {
const groupDocRef = firestore.collection('groups').doc(groupId)
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.'
)
}
}
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)) {
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]),
})
}

View File

@ -7,7 +7,7 @@ import {
} from '../../common/notification'
import { User } from '../../common/user'
import { Contract } from '../../common/contract'
import { getUserByUsername, getValues } from './utils'
import { getValues } from './utils'
import { Comment } from '../../common/comment'
import { uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
@ -16,6 +16,8 @@ import { getContractBetMetrics } from '../../common/calculate'
import { removeUndefinedProps } from '../../common/util/object'
import { TipTxn } from '../../common/txn'
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
import { Challenge } from '../../common/challenge'
import { richTextToString } from '../../common/util/parse'
const firestore = admin.firestore()
type user_to_reason_texts = {
@ -32,7 +34,7 @@ export const createNotification = async (
miscData?: {
contract?: Contract
relatedSourceType?: notification_source_types
relatedUserId?: string
recipients?: string[]
slug?: string
title?: string
}
@ -40,7 +42,7 @@ export const createNotification = async (
const {
contract: sourceContract,
relatedSourceType,
relatedUserId,
recipients,
slug,
title,
} = miscData ?? {}
@ -127,7 +129,7 @@ export const createNotification = async (
})
}
const notifyRepliedUsers = async (
const notifyRepliedUser = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string,
relatedSourceType: notification_source_types
@ -144,7 +146,7 @@ export const createNotification = async (
}
}
const notifyFollowedUser = async (
const notifyFollowedUser = (
userToReasonTexts: user_to_reason_texts,
followedUserId: string
) => {
@ -154,21 +156,13 @@ export const createNotification = async (
}
}
const notifyTaggedUsers = async (
const notifyTaggedUsers = (
userToReasonTexts: user_to_reason_texts,
sourceText: string
userIds: (string | undefined)[]
) => {
const taggedUsers = sourceText.match(/@\w+/g)
if (!taggedUsers) return
// await all get tagged users:
const users = await Promise.all(
taggedUsers.map(async (username) => {
return await getUserByUsername(username.slice(1))
})
)
users.forEach((taggedUser) => {
if (taggedUser && shouldGetNotification(taggedUser.id, userToReasonTexts))
userToReasonTexts[taggedUser.id] = {
userIds.forEach((id) => {
if (id && shouldGetNotification(id, userToReasonTexts))
userToReasonTexts[id] = {
reason: 'tagged_user',
}
})
@ -253,7 +247,7 @@ export const createNotification = async (
})
}
const notifyUserAddedToGroup = async (
const notifyUserAddedToGroup = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string
) => {
@ -275,11 +269,14 @@ export const createNotification = async (
const getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
if (sourceType === 'follow' && relatedUserId) {
await notifyFollowedUser(userToReasonTexts, relatedUserId)
} else if (sourceType === 'group' && relatedUserId) {
if (sourceUpdateType === 'created')
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
if (sourceType === 'follow' && recipients?.[0]) {
notifyFollowedUser(userToReasonTexts, recipients[0])
} else if (
sourceType === 'group' &&
sourceUpdateType === 'created' &&
recipients
) {
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
}
// The following functions need sourceContract to be defined.
@ -292,13 +289,9 @@ export const createNotification = async (
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
) {
if (sourceType === 'comment') {
if (relatedUserId && relatedSourceType)
await notifyRepliedUsers(
userToReasonTexts,
relatedUserId,
relatedSourceType
)
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText)
if (recipients?.[0] && relatedSourceType)
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
}
await notifyContractCreator(userToReasonTexts, sourceContract)
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
@ -307,6 +300,7 @@ export const createNotification = async (
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
await notifyUsersFollowers(userToReasonTexts)
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true,
@ -422,7 +416,7 @@ export const createGroupCommentNotification = async (
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: comment.text,
sourceText: richTextToString(comment.content),
sourceSlug,
sourceTitle: `${group.name}`,
isSeenOnHref: sourceSlug,
@ -478,3 +472,35 @@ export const createReferralNotification = async (
}
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
export const createChallengeAcceptedNotification = async (
challenger: User,
challengeCreator: User,
challenge: Challenge,
acceptedAmount: number,
contract: Contract
) => {
const notificationRef = firestore
.collection(`/users/${challengeCreator.id}/notifications`)
.doc()
const notification: Notification = {
id: notificationRef.id,
userId: challengeCreator.id,
reason: 'challenge_accepted',
createdTime: Date.now(),
isSeen: false,
sourceId: challenge.slug,
sourceType: 'challenge',
sourceUpdateType: 'updated',
sourceUserName: challenger.name,
sourceUserUsername: challenger.username,
sourceUserAvatarUrl: challenger.avatarUrl,
sourceText: acceptedAmount.toString(),
sourceContractCreatorUsername: contract.creatorUsername,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceContractId: contract.id,
sourceSlug: `/challenges/${challengeCreator.username}/${challenge.contractSlug}/${challenge.slug}`,
}
return await notificationRef.set(removeUndefinedProps(notification))
}

View File

@ -16,7 +16,7 @@ import {
cleanDisplayName,
cleanUsername,
} from '../../common/util/clean-username'
import { sendWelcomeEmail } from './emails'
import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails'
import { isWhitelisted } from '../../common/envs/constants'
import {
CATEGORIES_GROUP_SLUG_POSTFIX,
@ -96,9 +96,10 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
await addUserToDefaultGroups(user)
await sendWelcomeEmail(user, privateUser)
await sendPersonalFollowupEmail(user, privateUser)
await track(auth.uid, 'create user', { username }, { ip: req.ip })
return user
return { user, privateUser }
})
const firestore = admin.firestore()

View File

@ -128,7 +128,20 @@
<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; 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
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Thanks for
using Manifold Markets. Running low
@ -161,6 +174,51 @@
</table>
</td>
</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;">&nbsp;</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;">&nbsp;</p>
</div>
</td>
</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;">

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,5 @@
import * as dayjs from 'dayjs'
import { DOMAIN } from '../../common/envs/constants'
import { Answer } from '../../common/answer'
import { Bet } from '../../common/bet'
@ -14,9 +16,10 @@ import {
import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail } from './send-email'
import { sendTemplateEmail, sendTextEmail } from './send-email'
import { getPrivateUser, getUser } from './utils'
import { getFunctionUrl } from '../../common/api'
import { richTextToString } from '../../common/util/parse'
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
@ -73,9 +76,8 @@ export const sendMarketResolutionEmail = async (
// Modify template here:
// 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,
subject,
'market-resolved',
@ -151,7 +153,7 @@ export const sendWelcomeEmail = async (
const emailType = 'generic'
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
await sendTemplateEmail(
return await sendTemplateEmail(
privateUser.email,
'Welcome to Manifold Markets!',
'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 (
user: User,
privateUser: PrivateUser
@ -182,7 +221,7 @@ export const sendOneWeekBonusEmail = async (
const emailType = 'generic'
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
await sendTemplateEmail(
return await sendTemplateEmail(
privateUser.email,
'Manifold Markets one week anniversary gift',
'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 (
user: User,
privateUser: PrivateUser
@ -214,7 +284,7 @@ export const sendThankYouEmail = async (
const emailType = 'generic'
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
await sendTemplateEmail(
return await sendTemplateEmail(
privateUser.email,
'Thanks for your Manifold purchase',
'thank-you',
@ -249,7 +319,7 @@ export const sendMarketCloseEmail = async (
const emailType = 'market-resolve'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
await sendTemplateEmail(
return await sendTemplateEmail(
privateUser.email,
'Your market has closed',
'market-close',
@ -291,7 +361,8 @@ export const sendNewCommentEmail = async (
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
const { text } = comment
const { content } = comment
const text = richTextToString(content)
let betDescription = ''
if (bet) {
@ -307,7 +378,7 @@ export const sendNewCommentEmail = async (
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
const answerNumber = `#${answerId}`
await sendTemplateEmail(
return await sendTemplateEmail(
privateUser.email,
subject,
'market-answer-comment',
@ -330,7 +401,7 @@ export const sendNewCommentEmail = async (
bet.outcome
)}`
}
await sendTemplateEmail(
return await sendTemplateEmail(
privateUser.email,
subject,
'market-comment',
@ -375,7 +446,7 @@ export const sendNewAnswerEmail = async (
const subject = `New answer on ${question}`
const from = `${name} <info@manifold.markets>`
await sendTemplateEmail(
return await sendTemplateEmail(
privateUser.email,
subject,
'market-answer',

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

View File

@ -64,6 +64,8 @@ import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge'
import { getcustomtoken } from './get-custom-token'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any)
@ -87,6 +89,8 @@ const unsubscribeFunction = toCloudFunction(unsubscribe)
const stripeWebhookFunction = toCloudFunction(stripewebhook)
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge)
const getCustomTokenFunction = toCloudFunction(getcustomtoken)
export {
healthFunction as health,
@ -108,4 +112,6 @@ export {
stripeWebhookFunction as stripewebhook,
createCheckoutSessionFunction as createcheckoutsession,
getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge,
getCustomTokenFunction as getcustomtoken,
}

View File

@ -1,13 +1,13 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { uniq } from 'lodash'
import { compact, uniq } from 'lodash'
import { getContract, getUser, getValues } from './utils'
import { Comment } from '../../common/comment'
import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer'
import { createNotification } from './create-notification'
import { parseMentions, richTextToString } from '../../common/util/parse'
const firestore = admin.firestore()
@ -24,6 +24,11 @@ export const onCreateCommentOnContract = functions
if (!contract)
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 lastCommentTime = comment.createdTime
@ -68,18 +73,22 @@ export const onCreateCommentOnContract = functions
? 'answer'
: undefined
const relatedUserId = comment.replyToCommentId
const repliedUserId = comment.replyToCommentId
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
: answer?.userId
const recipients = uniq(
compact([...parseMentions(comment.content), repliedUserId])
)
await createNotification(
comment.id,
'comment',
'created',
commentCreator,
eventId,
comment.text,
{ contract, relatedSourceType, relatedUserId }
richTextToString(comment.content),
{ contract, relatedSourceType, recipients }
)
const recipientUserIds = uniq([

View File

@ -1,12 +1,17 @@
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 { Contract } from '../../common/contract'
import { richTextToString } from '../../common/util/parse'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core'
import { User } from 'common/user'
import { sendCreatorGuideEmail } from './emails'
export const onCreateContract = functions.firestore
.document('contracts/{contractId}')
export const onCreateContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
.firestore.document('contracts/{contractId}')
.onCreate(async (snapshot, context) => {
const contract = snapshot.data() as Contract
const { eventId } = context
@ -14,13 +19,35 @@ export const onCreateContract = functions.firestore
const contractCreator = await getUser(contract.creatorId)
if (!contractCreator) throw new Error('Could not find contract creator')
const desc = contract.description as JSONContent
const mentioned = parseMentions(desc)
await createNotification(
contract.id,
'contract',
'created',
contractCreator,
eventId,
richTextToString(contract.description as JSONContent),
{ contract }
richTextToString(desc),
{ contract, recipients: mentioned }
)
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)
}

View File

@ -12,19 +12,17 @@ export const onCreateGroup = functions.firestore
const groupCreator = await getUser(group.creatorId)
if (!groupCreator) throw new Error('Could not find group creator')
// create notifications for all members of the group
for (const memberId of group.memberIds) {
await createNotification(
group.id,
'group',
'created',
groupCreator,
eventId,
group.about,
{
relatedUserId: memberId,
slug: group.slug,
title: group.name,
}
)
}
await createNotification(
group.id,
'group',
'created',
groupCreator,
eventId,
group.about,
{
recipients: group.memberIds,
slug: group.slug,
title: group.name,
}
)
})

View File

@ -30,7 +30,7 @@ export const onFollowUser = functions.firestore
followingUser,
eventId,
'',
{ relatedUserId: follow.userId }
{ recipients: [follow.userId] }
)
})

View File

@ -82,7 +82,22 @@ export const placebet = newEndpoint({}, async (req, auth) => {
(outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
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(
getUnfilledBetsQuery(contractDoc)

View File

@ -1,6 +1,6 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
import { difference, mapValues, groupBy, sumBy } from 'lodash'
import {
Contract,
@ -18,10 +18,12 @@ import {
groupPayoutsByUser,
Payout,
} from '../../common/payouts'
import { isAdmin } from '../../common/envs/constants'
import { isManifoldId } from '../../common/envs/constants'
import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api'
import { getContractBetMetrics } from '../../common/calculate'
import { floatingEqual } from '../../common/util/math'
const bodySchema = z.object({
contractId: z.string(),
@ -82,7 +84,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
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')
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)
await sendResolutionEmails(
openBets,
bets,
userPayoutsWithoutLoans,
creator,
creatorPayout,
@ -188,7 +190,7 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => {
}
const sendResolutionEmails = async (
openBets: Bet[],
bets: Bet[],
userPayouts: { [userId: string]: number },
creator: User,
creatorPayout: number,
@ -197,14 +199,15 @@ const sendResolutionEmails = async (
resolutionProbability?: number,
resolutions?: { [outcome: string]: number }
) => {
const nonWinners = difference(
uniq(openBets.map(({ userId }) => userId)),
Object.keys(userPayouts)
)
const investedByUser = mapValues(
groupBy(openBets, (bet) => bet.userId),
(bets) => sumBy(bets, (bet) => bet.amount)
groupBy(bets, (bet) => bet.userId),
(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 = [
...Object.entries(userPayouts),
...nonWinners.map((userId) => [userId, 0] as const),

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

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

View File

@ -1,27 +1,35 @@
import * as mailgun from 'mailgun-js'
import { tryOrLogError } from './utils'
const initMailgun = () => {
const apiKey = process.env.MAILGUN_KEY as string
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 = {
from: 'Manifold Markets <info@manifold.markets>',
...options,
from: options?.from ?? 'Manifold Markets <info@manifold.markets>',
to,
subject,
text,
// Don't rewrite urls in plaintext emails
'o:tracking-clicks': 'htmlonly',
}
const mg = initMailgun()
return mg.messages().send(data, (error) => {
if (error) console.log('Error sending email', error)
else console.log('Sent text email', to, subject)
})
const mg = initMailgun().messages()
const result = await tryOrLogError(mg.send(data))
if (result != null) {
console.log('Sent text email', to, subject)
}
return result
}
export const sendTemplateEmail = (
export const sendTemplateEmail = async (
to: string,
subject: string,
templateId: string,
@ -35,11 +43,13 @@ export const sendTemplateEmail = (
subject,
template: templateId,
'h:X-Mailgun-Variables': JSON.stringify(templateData),
'o:tag': templateId,
'o:tracking': true,
}
const mg = initMailgun()
return mg.messages().send(data, (error) => {
if (error) console.log('Error sending email', error)
else console.log('Sent template email', templateId, to, subject)
})
const mg = initMailgun().messages()
const result = await tryOrLogError(mg.send(data))
if (result != null) {
console.log('Sent template email', templateId, to, subject)
}
return result
}

View File

@ -26,6 +26,7 @@ import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
import { getcustomtoken } from './get-custom-token'
type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express()
@ -64,6 +65,7 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe)
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addEndpointRoute('/getcustomtoken', getcustomtoken)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
app.listen(PORT)

View File

@ -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 = () => {
return admin.instanceId().app.options.projectId === 'mantic-markets'
}

View File

@ -1,32 +1,35 @@
# Installing
1. `yarn install`
2. `yarn start`
3. `Y` to `Set up and develop “~path/to/the/repo/manifold”? [Y/n]`
4. `Manifold Markets` to `Which scope should contain your project? [Y/n] `
5. `Y` to `Link to existing project? [Y/n] `
6. `opengraph-image` to `Whats the name of your existing project?`
# Quickstart
1. To get started: `yarn install`
2. To test locally: `yarn start`
1. To test locally: `yarn start`
The local image preview is broken for some reason; but the service works.
E.g. try `http://localhost:3000/manifold.png`
3. To deploy: push to Github
2. To deploy: push to Github
- note: (Not `dev` because that's reserved for Vercel)
- note2: (Or `cd .. && vercel --prod`, I think)
For more info, see Contributing.md
- note2: You may have to configure Vercel the first time:
```
$ yarn start
yarn run v1.22.10
$ cd .. && vercel dev
Vercel CLI 23.1.2 dev (beta) — https://vercel.com/feedback
? Set up and develop “~/Code/mantic”? [Y/n] y
? Which scope should contain your project? Mantic Markets
? Found project “mantic/mantic”. Link to it? [Y/n] n
? Link to different existing project? [Y/n] y
? Whats the name of your existing project? manifold-og-image
```
- note2: (Not `dev` because that's reserved for Vercel)
- note3: (Or `cd .. && vercel --prod`, I think)
(Everything below is from the original repo)
# Development
- Code of interest is contained in the `api/_lib` directory, i.e. `template.ts` is the page that renders the UI.
- Edit `parseRequest(req: IncomingMessage)` in `parser.ts` to add/edit query parameters.
- Note: When testing a remote branch on vercel, the og-image previews that apps load will point to
`https://manifold-og-image.vercel.app/m.png?question=etc.`, (see relevant code in `SEO.tsx`) and not your remote branch.
You have to find your opengraph-image branch's url and replace the part before `m.png` with it.
- You can also preview the image locally, e.g. `http://localhost:3000/m.png?question=etc.`
- Every time you change the template code you'll have to change the query parameter slightly as the image will likely be cached.
- You can find your remote branch's opengraph-image url by click `Visit Preview` on Github:
![](../../../../../Desktop/Screen Shot 2022-08-01 at 2.56.42 PM.png)
# [Open Graph Image as a Service](https://og-image.vercel.app)
<a href="https://twitter.com/vercel">

View File

@ -0,0 +1,203 @@
import { sanitizeHtml } from './sanitizer'
import { ParsedRequest } from './types'
function getCss(theme: string, fontSize: string) {
let background = 'white'
let foreground = 'black'
let radial = 'lightgray'
if (theme === 'dark') {
background = 'black'
foreground = 'white'
radial = 'dimgray'
}
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
return `
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
body {
background: ${background};
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
background-size: 100px 100px;
height: 100vh;
font-family: "Readex Pro", sans-serif;
}
code {
color: #D400FF;
font-family: 'Vera';
white-space: pre-wrap;
letter-spacing: -5px;
}
code:before, code:after {
content: '\`';
}
.logo-wrapper {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
}
.logo {
margin: 0 75px;
}
.plus {
color: #BBB;
font-family: Times New Roman, Verdana;
font-size: 100px;
}
.spacer {
margin: 150px;
}
.emoji {
height: 1em;
width: 1em;
margin: 0 .05em 0 .1em;
vertical-align: -0.1em;
}
.heading {
font-family: 'Major Mono Display', monospace;
font-size: ${sanitizeHtml(fontSize)};
font-style: normal;
color: ${foreground};
line-height: 1.8;
}
.font-major-mono {
font-family: "Major Mono Display", monospace;
}
.text-primary {
color: #11b981;
}
`
}
export function getChallengeHtml(parsedReq: ParsedRequest) {
const {
theme,
fontSize,
question,
creatorName,
creatorAvatarUrl,
challengerAmount,
challengerOutcome,
creatorAmount,
creatorOutcome,
acceptedName,
acceptedAvatarUrl,
} = parsedReq
const MAX_QUESTION_CHARS = 78
const truncatedQuestion =
question.length > MAX_QUESTION_CHARS
? question.slice(0, MAX_QUESTION_CHARS) + '...'
: question
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
const hideAcceptedAvatar = acceptedAvatarUrl ? '' : 'hidden'
const accepted = acceptedName !== ''
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Generated Image</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
</head>
<style>
${getCss(theme, fontSize)}
</style>
<body>
<div class="px-24">
<div class="flex flex-col justify-between gap-16 pt-2">
<div class="flex flex-col text-indigo-700 mt-4 text-5xl leading-tight text-center">
${truncatedQuestion}
</div>
<div class="flex flex-row grid grid-cols-3">
<div class="flex flex-col justify-center items-center ${
creatorOutcome === 'YES' ? 'text-primary' : 'text-red-500'
}">
<!-- Creator user column-->
<div class="flex flex-col align-bottom gap-6 items-center justify-center">
<p class="text-gray-900 text-4xl">${creatorName}</p>
<img
class="h-36 w-36 rounded-full bg-white flex items-center justify-center ${hideAvatar}"
src="${creatorAvatarUrl}"
alt=""
/>
</div>
<div class="flex flex-row justify-center items-center gap-3 mt-6">
<div class="text-5xl">${'M$' + creatorAmount}</div>
<div class="text-4xl">${'on'}</div>
<div class="text-5xl ">${creatorOutcome}</div>
</div>
</div>
<!-- VS-->
<div class="flex flex-col text-gray-900 text-6xl mt-8 text-center">
VS
</div>
<div class="flex flex-col justify-center items-center ${
challengerOutcome === 'YES' ? 'text-primary' : 'text-red-500'
}">
<!-- Unaccepted user column-->
<div class="flex flex-col align-bottom gap-6 items-center justify-center
${accepted ? 'hidden' : ''}">
<p class="text-gray-900 text-4xl">You</p>
<img
class="h-36 w-36 rounded-full bg-white flex items-center justify-center "
src="https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png"
alt=""
/>
</div>
<!-- Accepted user column-->
<div class="flex flex-col align-bottom gap-6 items-center justify-center">
<p class="text-gray-900 text-4xl">${acceptedName}</p>
<img
class="h-36 w-36 rounded-full bg-white flex items-center justify-center ${hideAcceptedAvatar}"
src="${acceptedAvatarUrl}"
alt=""
/>
</div>
<div class="flex flex-row justify-center items-center gap-3 mt-6">
<div class="text-5xl">${'M$' + challengerAmount}</div>
<div class="text-4xl">${'on'}</div>
<div class="text-5xl ">${challengerOutcome}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Manifold logo -->
<div class="flex flex-row justify-center absolute bottom-4 left-[24rem]">
<a class="flex flex-row gap-3" href="/">
<img
class="sm:h-12 sm:w-12"
src="https:&#x2F;&#x2F;manifold.markets&#x2F;logo.png"
width="40"
height="40"
alt=''
/>
<div
class="hidden sm:flex font-major-mono lowercase mt-1 sm:text-3xl md:whitespace-nowrap"
>
Manifold Markets
</div></a>
</div>
</div>
</body>
</html>`
}

View File

@ -16,10 +16,19 @@ export function parseRequest(req: IncomingMessage) {
// Attributes for Manifold card:
question,
probability,
numericValue,
metadata,
creatorName,
creatorUsername,
creatorAvatarUrl,
// Challenge attributes:
challengerAmount,
challengerOutcome,
creatorAmount,
creatorOutcome,
acceptedName,
acceptedAvatarUrl,
} = query || {}
if (Array.isArray(fontSize)) {
@ -63,10 +72,17 @@ export function parseRequest(req: IncomingMessage) {
question:
getString(question) || 'Will you create a prediction market on Manifold?',
probability: getString(probability),
numericValue: getString(numericValue) || '',
metadata: getString(metadata) || 'Jan 1 &nbsp;•&nbsp; M$ 123 pool',
creatorName: getString(creatorName) || 'Manifold Markets',
creatorUsername: getString(creatorUsername) || 'ManifoldMarkets',
creatorAvatarUrl: getString(creatorAvatarUrl) || '',
challengerAmount: getString(challengerAmount) || '',
challengerOutcome: getString(challengerOutcome) || '',
creatorAmount: getString(creatorAmount) || '',
creatorOutcome: getString(creatorOutcome) || '',
acceptedName: getString(acceptedName) || '',
acceptedAvatarUrl: getString(acceptedAvatarUrl) || '',
}
parsedRequest.images = getDefaultImages(parsedRequest.images)
return parsedRequest

View File

@ -91,6 +91,7 @@ export function getHtml(parsedReq: ParsedRequest) {
creatorName,
creatorUsername,
creatorAvatarUrl,
numericValue,
} = parsedReq
const MAX_QUESTION_CHARS = 100
const truncatedQuestion =
@ -126,7 +127,7 @@ export function getHtml(parsedReq: ParsedRequest) {
</div>
</div>
<!-- Mantic logo -->
<!-- Manifold logo -->
<div class="absolute right-24 top-8">
<a class="flex flex-row gap-3" href="/"
><img
@ -150,6 +151,12 @@ export function getHtml(parsedReq: ParsedRequest) {
<div class="flex flex-col text-primary">
<div class="text-8xl">${probability}</div>
<div class="text-4xl">${probability !== '' ? 'chance' : ''}</div>
<span class='text-blue-500 text-center'>
<div class="text-8xl ">${
numericValue !== '' && probability === '' ? numericValue : ''
}</div>
<div class="text-4xl">${numericValue !== '' ? 'expected' : ''}</div>
</span>
</div>
</div>

View File

@ -1,21 +1,29 @@
export type FileType = "png" | "jpeg";
export type Theme = "light" | "dark";
export type FileType = 'png' | 'jpeg'
export type Theme = 'light' | 'dark'
export interface ParsedRequest {
fileType: FileType;
text: string;
theme: Theme;
md: boolean;
fontSize: string;
images: string[];
widths: string[];
heights: string[];
fileType: FileType
text: string
theme: Theme
md: boolean
fontSize: string
images: string[]
widths: string[]
heights: string[]
// Attributes for Manifold card:
question: string;
probability: string;
metadata: string;
creatorName: string;
creatorUsername: string;
creatorAvatarUrl: string;
question: string
probability: string
numericValue: string
metadata: string
creatorName: string
creatorUsername: string
creatorAvatarUrl: string
// Challenge attributes:
challengerAmount: string
challengerOutcome: string
creatorAmount: string
creatorOutcome: string
acceptedName: string
acceptedAvatarUrl: string
}

View File

@ -1,36 +1,38 @@
import { IncomingMessage, ServerResponse } from "http";
import { parseRequest } from "./_lib/parser";
import { getScreenshot } from "./_lib/chromium";
import { getHtml } from "./_lib/template";
import { IncomingMessage, ServerResponse } from 'http'
import { parseRequest } from './_lib/parser'
import { getScreenshot } from './_lib/chromium'
import { getHtml } from './_lib/template'
import { getChallengeHtml } from './_lib/challenge-template'
const isDev = !process.env.AWS_REGION;
const isHtmlDebug = process.env.OG_HTML_DEBUG === "1";
const isDev = !process.env.AWS_REGION
const isHtmlDebug = process.env.OG_HTML_DEBUG === '1'
export default async function handler(
req: IncomingMessage,
res: ServerResponse
) {
try {
const parsedReq = parseRequest(req);
const html = getHtml(parsedReq);
const parsedReq = parseRequest(req)
let html = getHtml(parsedReq)
if (parsedReq.challengerOutcome) html = getChallengeHtml(parsedReq)
if (isHtmlDebug) {
res.setHeader("Content-Type", "text/html");
res.end(html);
return;
res.setHeader('Content-Type', 'text/html')
res.end(html)
return
}
const { fileType } = parsedReq;
const file = await getScreenshot(html, fileType, isDev);
res.statusCode = 200;
res.setHeader("Content-Type", `image/${fileType}`);
const { fileType } = parsedReq
const file = await getScreenshot(html, fileType, isDev)
res.statusCode = 200
res.setHeader('Content-Type', `image/${fileType}`)
res.setHeader(
"Cache-Control",
'Cache-Control',
`public, immutable, no-transform, s-maxage=31536000, max-age=31536000`
);
res.end(file);
)
res.end(file)
} catch (e) {
res.statusCode = 500;
res.setHeader("Content-Type", "text/html");
res.end("<h1>Internal Error</h1><p>Sorry, there was a problem</p>");
console.error(e);
res.statusCode = 500
res.setHeader('Content-Type', 'text/html')
res.end('<h1>Internal Error</h1><p>Sorry, there was a problem</p>')
console.error(e)
}
}

View File

@ -14,6 +14,7 @@
"devDependencies": {
"@typescript-eslint/eslint-plugin": "5.25.0",
"@typescript-eslint/parser": "5.25.0",
"@types/node": "16.11.11",
"concurrently": "6.5.1",
"eslint": "8.15.0",
"eslint-plugin-lodash": "^7.4.0",

View File

@ -5,6 +5,7 @@ module.exports = {
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'plugin:@next/next/recommended',
'prettier',
],
rules: {
'@typescript-eslint/no-empty-function': 'off',

View File

@ -1,5 +1,6 @@
import { ReactNode } from 'react'
import Head from 'next/head'
import { Challenge } from 'common/challenge'
export type OgCardProps = {
question: string
@ -8,27 +9,51 @@ export type OgCardProps = {
creatorName: string
creatorUsername: string
creatorAvatarUrl?: string
numericValue?: string
}
function buildCardUrl(props: OgCardProps) {
function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
const {
creatorAmount,
acceptances,
acceptorAmount,
creatorOutcome,
acceptorOutcome,
} = challenge || {}
const { userName, userAvatarUrl } = acceptances?.[0] ?? {}
const probabilityParam =
props.probability === undefined
? ''
: `&probability=${encodeURIComponent(props.probability ?? '')}`
const numericValueParam =
props.numericValue === undefined
? ''
: `&numericValue=${encodeURIComponent(props.numericValue ?? '')}`
const creatorAvatarUrlParam =
props.creatorAvatarUrl === undefined
? ''
: `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}`
const challengeUrlParams = challenge
? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` +
`&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` +
`&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}`
: ''
// URL encode each of the props, then add them as query params
return (
`https://manifold-og-image.vercel.app/m.png` +
`?question=${encodeURIComponent(props.question)}` +
probabilityParam +
numericValueParam +
`&metadata=${encodeURIComponent(props.metadata)}` +
`&creatorName=${encodeURIComponent(props.creatorName)}` +
creatorAvatarUrlParam +
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}`
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}` +
challengeUrlParams
)
}
@ -38,8 +63,9 @@ export function SEO(props: {
url?: string
children?: ReactNode
ogCardProps?: OgCardProps
challenge?: Challenge
}) {
const { title, description, url, children, ogCardProps } = props
const { title, description, url, children, ogCardProps, challenge } = props
return (
<Head>
@ -71,13 +97,13 @@ export function SEO(props: {
<>
<meta
property="og:image"
content={buildCardUrl(ogCardProps)}
content={buildCardUrl(ogCardProps, challenge)}
key="image1"
/>
<meta name="twitter:card" content="summary_large_image" key="card" />
<meta
name="twitter:image"
content={buildCardUrl(ogCardProps)}
content={buildCardUrl(ogCardProps, challenge)}
key="image2"
/>
</>

View File

@ -26,6 +26,7 @@ import { Bet } from 'common/bet'
import { track } from 'web/lib/service/analytics'
import { SignUpPrompt } from '../sign-up-prompt'
import { isIOS } from 'web/lib/util/device'
import { AlertBox } from '../alert-box'
export function AnswerBetPanel(props: {
answer: Answer
@ -113,6 +114,8 @@ export function AnswerBetPanel(props: {
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = formatPercent(currentReturn)
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
return (
<Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
<Row className="items-center justify-between self-stretch">
@ -139,6 +142,22 @@ export function AnswerBetPanel(props: {
disabled={isSubmitting}
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">
<Row className="items-center justify-between text-sm">
<div className="text-gray-500">Probability</div>

View File

@ -1,26 +1,23 @@
import { MAX_ANSWER_LENGTH } from 'common/answer'
import { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { XIcon } from '@heroicons/react/solid'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
export function MultipleChoiceAnswers(props: {
answers: string[]
setAnswers: (answers: string[]) => void
}) {
const [answers, setInternalAnswers] = useState(['', '', ''])
const { answers, setAnswers } = props
const setAnswer = (i: number, answer: string) => {
const newAnswers = setElement(answers, i, answer)
setInternalAnswers(newAnswers)
props.setAnswers(newAnswers)
setAnswers(newAnswers)
}
const removeAnswer = (i: number) => {
const newAnswers = answers.slice(0, i).concat(answers.slice(i + 1))
setInternalAnswers(newAnswers)
props.setAnswers(newAnswers)
setAnswers(newAnswers)
}
const addAnswer = () => setAnswer(answers.length, '')
@ -28,7 +25,7 @@ export function MultipleChoiceAnswers(props: {
return (
<Col>
{answers.map((answer, i) => (
<Row className="mb-2 items-center align-middle">
<Row className="mb-2 items-center gap-2 align-middle">
{i + 1}.{' '}
<Textarea
value={answer}
@ -40,17 +37,22 @@ export function MultipleChoiceAnswers(props: {
/>
{answers.length > 2 && (
<button
className="btn btn-xs btn-outline ml-2"
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>
)}
</Row>
))}
<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
</button>
</Row>

View File

@ -1,23 +1,24 @@
import { createContext, useEffect } from 'react'
import { User } from 'common/user'
import { ReactNode, createContext, useEffect } from 'react'
import { onIdTokenChanged } from 'firebase/auth'
import {
UserAndPrivateUser,
auth,
listenForUser,
getUser,
listenForPrivateUser,
getUserAndPrivateUser,
setCachedReferralInfoForUser,
} from 'web/lib/firebase/users'
import { deleteAuthCookies, setAuthCookies } from 'web/lib/firebase/auth'
import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth'
import { createUser } from 'web/lib/firebase/api'
import { randomString } from 'common/util/random'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
// 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).
type AuthUser = undefined | null | User
// the user is not logged in (null), or we know the user is logged in.
type AuthUser = undefined | null | UserAndPrivateUser
const CACHED_USER_KEY = 'CACHED_USER_KEY'
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
const ensureDeviceToken = () => {
let deviceToken = localStorage.getItem('device-token')
@ -28,48 +29,72 @@ const ensureDeviceToken = () => {
return deviceToken
}
export const AuthContext = createContext<AuthUser>(null)
export const AuthContext = createContext<AuthUser>(undefined)
export function AuthProvider({ children }: any) {
const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(undefined)
export function AuthProvider(props: {
children: ReactNode
serverUser?: AuthUser
}) {
const { children, serverUser } = props
const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(serverUser)
useEffect(() => {
const cachedUser = localStorage.getItem(CACHED_USER_KEY)
setAuthUser(cachedUser && JSON.parse(cachedUser))
}, [setAuthUser])
if (serverUser === undefined) {
const cachedUser = localStorage.getItem(CACHED_USER_KEY)
setAuthUser(cachedUser && JSON.parse(cachedUser))
}
}, [setAuthUser, serverUser])
useEffect(() => {
return onIdTokenChanged(auth, async (fbUser) => {
if (fbUser) {
setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken)
let user = await getUser(fbUser.uid)
if (!user) {
setTokenCookies({
id: await fbUser.getIdToken(),
refresh: fbUser.refreshToken,
})
let current = await getUserAndPrivateUser(fbUser.uid)
if (!current.user || !current.privateUser) {
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.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(user))
setCachedReferralInfoForUser(user)
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current))
setCachedReferralInfoForUser(current.user)
} else {
// User logged out; reset to null
deleteAuthCookies()
deleteTokenCookies()
setAuthUser(null)
localStorage.removeItem(CACHED_USER_KEY)
}
})
}, [setAuthUser])
const authUserId = authUser?.id
const authUsername = authUser?.username
const uid = authUser?.user.id
const username = authUser?.user.username
useEffect(() => {
if (authUserId && authUsername) {
identifyUser(authUserId)
setUserProperty('username', authUsername)
return listenForUser(authUserId, setAuthUser)
if (uid && username) {
identifyUser(uid)
setUserProperty('username', username)
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 (
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>

View File

@ -1,6 +1,6 @@
import Router from 'next/router'
import clsx from 'clsx'
import { MouseEvent } from 'react'
import { MouseEvent, useState } from 'react'
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
export function Avatar(props: {
@ -10,7 +10,8 @@ export function Avatar(props: {
size?: number | 'xs' | 'sm'
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 onClick =
@ -35,6 +36,11 @@ export function Avatar(props: {
src={avatarUrl}
onClick={onClick}
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
@ -47,14 +53,21 @@ export function Avatar(props: {
)
}
export function EmptyAvatar(props: { size?: number; multi?: boolean }) {
const { size = 8, multi } = props
export function EmptyAvatar(props: {
className?: string
size?: number
multi?: boolean
}) {
const { className, size = 8, multi } = props
const insize = size - 3
const Icon = multi ? UsersIcon : UserIcon
return (
<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 />
</div>

View File

@ -16,8 +16,7 @@ import {
import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet'
import { User } from 'web/lib/firebase/users'
import { Bet, LimitBet } from 'common/bet'
import { APIError, placeBet } from 'web/lib/firebase/api'
import { sellShares } from 'web/lib/firebase/api'
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
import { AmountInput, BuyAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip'
import {
@ -255,6 +254,7 @@ function BuyPanel(props: {
const resultProb = getCpmmProbability(newPool, newP)
const probStayedSame =
formatPercent(resultProb) === formatPercent(initialProb)
const probChange = Math.abs(resultProb - initialProb)
const currentPayout = newBet.shares
@ -306,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">
<Row className="items-center justify-between text-sm">
<div className="text-gray-500">
@ -351,7 +364,7 @@ function BuyPanel(props: {
{user && (
<button
className={clsx(
'btn flex-1',
'btn mb-2 flex-1',
betDisabled
? 'btn-disabled'
: outcome === 'YES'
@ -435,8 +448,6 @@ function LimitOrderPanel(props: {
const yesAmount = shares * (yesLimitProb ?? 1)
const noAmount = shares * (1 - (noLimitProb ?? 0))
const profitIfBothFilled = shares - (yesAmount + noAmount)
function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false)
setBetAmount(newAmount)
@ -485,6 +496,8 @@ function LimitOrderPanel(props: {
setIsSubmitting(false)
setWasSubmitted(true)
setBetAmount(undefined)
setLowLimitProb(undefined)
setHighLimitProb(undefined)
if (onBuySuccess) onBuySuccess()
})
@ -544,6 +557,8 @@ function LimitOrderPanel(props: {
)
const noReturnPercent = formatPercent(noReturn)
const profitIfBothFilled = shares - (yesAmount + noAmount) - yesFees - noFees
return (
<Col className={hidden ? 'hidden' : ''}>
<Row className="mt-1 items-center gap-4">

View File

@ -1,5 +1,14 @@
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 { useEffect, useMemo, useState } from 'react'
import clsx from 'clsx'
@ -19,6 +28,7 @@ import {
Contract,
contractPath,
getBinaryProbPercent,
getContractFromId,
} from 'web/lib/firebase/contracts'
import { Row } from './layout/row'
import { UserLink } from './user-page'
@ -41,10 +51,12 @@ import { trackLatency } from 'web/lib/firebase/tracking'
import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user'
import { useUserBets } from 'web/hooks/use-user-bets'
import { SellSharesModal } from './sell-modal'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet'
import { floatingEqual } from 'common/util/math'
import { filterDefined } from 'common/util/array'
import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets'
@ -52,25 +64,35 @@ type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
const CONTRACTS_PER_PAGE = 50
const JUNE_1_2022 = new Date('2022-06-01T00:00:00.000Z').valueOf()
export function BetsList(props: {
user: User
bets: Bet[] | undefined
contractsById: { [id: string]: Contract } | undefined
hideBetsBefore?: number
}) {
const { user, bets: allBets, contractsById, hideBetsBefore } = props
export function BetsList(props: { user: User }) {
const { user } = props
const signedInUser = useUser()
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
// NOTE: This means public profits also begin on 06-01-2022 as well.
const bets = useMemo(
() => allBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)),
[allBets, hideBetsBefore]
() => userBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)),
[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 [filter, setFilter] = useState<BetFilter>('open')
const [page, setPage] = useState(0)
@ -406,95 +428,105 @@ export function BetsSummary(props: {
: 'NO'
: 'YES'
const canSell =
isYourBets &&
isCpmm &&
(isBinary || isPseudoNumeric) &&
!isClosed &&
!resolution &&
hasShares &&
sharesOutcome &&
user
return (
<Row className={clsx('flex-wrap gap-4 sm:flex-nowrap sm:gap-6', className)}>
{!isCpmm && (
<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>
)}
{resolution ? (
<Col>
<div className="text-sm text-gray-500">Payout</div>
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
<div className="whitespace-nowrap">
{formatMoney(payout)} <ProfitBadge profitPercent={profitPercent} />
{formatMoney(profit)} <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 &&
(isBinary || isPseudoNumeric) &&
!isClosed &&
!resolution &&
hasShares &&
sharesOutcome &&
user && (
<>
<button
className="btn btn-sm ml-2"
onClick={() => setShowSellModal(true)}
>
Sell
</button>
{showSellModal && (
<SellSharesModal
contract={contract}
user={user}
userBets={bets}
shares={totalShares[sharesOutcome]}
sharesOutcome={sharesOutcome}
setOpen={setShowSellModal}
/>
)}
</>
{canSell && (
<>
<button
className="btn btn-sm self-end"
onClick={() => setShowSellModal(true)}
>
Sell
</button>
{showSellModal && (
<SellSharesModal
contract={contract}
user={user}
userBets={bets}
shares={totalShares[sharesOutcome]}
sharesOutcome={sharesOutcome}
setOpen={setShowSellModal}
/>
)}
</div>
</Col>
</Row>
</>
)}
</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>
</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>
</Col>
)
}

View File

@ -5,8 +5,16 @@ export function Button(props: {
className?: string
onClick?: () => void
children?: ReactNode
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
color?: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray' | 'gray-white'
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
color?:
| 'green'
| 'red'
| 'blue'
| 'indigo'
| 'yellow'
| 'gray'
| 'gradient'
| 'gray-white'
type?: 'button' | 'reset' | 'submit'
disabled?: boolean
}) {
@ -21,11 +29,13 @@ export function Button(props: {
} = props
const sizeClasses = {
'2xs': 'px-2 py-1 text-xs',
xs: 'px-2.5 py-1.5 text-sm',
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base',
xl: 'px-6 py-3 text-base',
'2xl': 'px-6 py-3 text-xl',
}[size]
return (
@ -39,10 +49,11 @@ export function Button(props: {
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-7 hover:bg-greyscale-2',
color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200',
color === 'gradient' &&
'bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 bg-white',
'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200',
className
)}
disabled={disabled}

View File

@ -0,0 +1,125 @@
import { User } from 'common/user'
import { Contract } from 'common/contract'
import { Challenge } from 'common/challenge'
import { useEffect, useState } from 'react'
import { SignUpPrompt } from 'web/components/sign-up-prompt'
import { acceptChallenge, APIError } from 'web/lib/firebase/api'
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
import { Title } from 'web/components/title'
import { Row } from 'web/components/layout/row'
import { formatMoney } from 'common/util/format'
import { Button } from 'web/components/button'
import clsx from 'clsx'
export function AcceptChallengeButton(props: {
user: User | null | undefined
contract: Contract
challenge: Challenge
}) {
const { user, challenge, contract } = props
const [open, setOpen] = useState(false)
const [errorText, setErrorText] = useState('')
const [loading, setLoading] = useState(false)
const { acceptorAmount, creatorAmount } = challenge
useEffect(() => {
setErrorText('')
}, [open])
if (!user) return <SignUpPrompt label="Accept this bet" className="mt-4" />
const iAcceptChallenge = () => {
setLoading(true)
if (user.id === challenge.creatorId) {
setErrorText('You cannot accept your own challenge!')
setLoading(false)
return
}
acceptChallenge({
contractId: contract.id,
challengeSlug: challenge.slug,
outcomeType: contract.outcomeType,
closeTime: contract.closeTime,
})
.then((r) => {
console.log('accepted challenge. Result:', r)
setLoading(false)
})
.catch((e) => {
setLoading(false)
if (e instanceof APIError) {
setErrorText(e.toString())
} else {
console.error(e)
setErrorText('Error accepting challenge')
}
})
}
return (
<>
<Modal open={open} setOpen={(newOpen) => setOpen(newOpen)} size={'sm'}>
<Col className="gap-4 rounded-md bg-white px-8 py-6">
<Col className={'gap-4'}>
<div className={'flex flex-row justify-start '}>
<Title text={"So you're in?"} className={'!my-2'} />
</div>
<Col className="w-full items-center justify-start gap-2">
<Row className={'w-full justify-start gap-20'}>
<span className={'min-w-[4rem] font-bold'}>Cost to you:</span>{' '}
<span className={'text-red-500'}>
{formatMoney(acceptorAmount)}
</span>
</Row>
<Col className={'w-full items-center justify-start'}>
<Row className={'w-full justify-start gap-10'}>
<span className={'min-w-[4rem] font-bold'}>
Potential payout:
</span>{' '}
<Row className={'items-center justify-center'}>
<span className={'text-primary'}>
{formatMoney(creatorAmount + acceptorAmount)}
</span>
</Row>
</Row>
</Col>
</Col>
<Row className={'mt-4 justify-end gap-4'}>
<Button
color={'gray'}
disabled={loading}
onClick={() => setOpen(false)}
className={clsx('whitespace-nowrap')}
>
I'm out
</Button>
<Button
color={'indigo'}
disabled={loading}
onClick={() => iAcceptChallenge()}
className={clsx('min-w-[6rem] whitespace-nowrap')}
>
I'm in
</Button>
</Row>
<Row>
<span className={'text-error'}>{errorText}</span>
</Row>
</Col>
</Col>
</Modal>
{challenge.creatorId != user.id && (
<Button
color="gradient"
size="2xl"
onClick={() => setOpen(true)}
className={clsx('whitespace-nowrap')}
>
Accept bet
</Button>
)}
</>
)
}

View File

@ -0,0 +1,259 @@
import clsx from 'clsx'
import dayjs from 'dayjs'
import React, { useEffect, useState } from 'react'
import { LinkIcon, SwitchVerticalIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { Title } from '../title'
import { User } from 'common/user'
import { Modal } from 'web/components/layout/modal'
import { Button } from '../button'
import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
import { BinaryContract } from 'common/contract'
import { SiteLink } from 'web/components/site-link'
import { formatMoney } from 'common/util/format'
import { NoLabel, YesLabel } from '../outcome-label'
import { QRCode } from '../qr-code'
import { copyToClipboard } from 'web/lib/util/copy'
import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate'
import { track } from 'web/lib/service/analytics'
type challengeInfo = {
amount: number
expiresTime: number | null
message: string
outcome: 'YES' | 'NO' | number
acceptorAmount: number
}
export function CreateChallengeModal(props: {
user: User | null | undefined
contract: BinaryContract
isOpen: boolean
setOpen: (open: boolean) => void
}) {
const { user, contract, isOpen, setOpen } = props
const [challengeSlug, setChallengeSlug] = useState('')
return (
<Modal open={isOpen} setOpen={setOpen}>
<Col className="gap-4 rounded-md bg-white px-8 py-6">
{/*// add a sign up to challenge button?*/}
{user && (
<CreateChallengeForm
user={user}
contract={contract}
onCreate={async (newChallenge) => {
const challenge = await createChallenge({
creator: user,
creatorAmount: newChallenge.amount,
expiresTime: newChallenge.expiresTime,
message: newChallenge.message,
acceptorAmount: newChallenge.acceptorAmount,
outcome: newChallenge.outcome,
contract: contract,
})
if (challenge) {
setChallengeSlug(getChallengeUrl(challenge))
track('challenge created', {
creator: user.username,
amount: newChallenge.amount,
contractId: contract.id,
})
}
}}
challengeSlug={challengeSlug}
/>
)}
</Col>
</Modal>
)
}
function CreateChallengeForm(props: {
user: User
contract: BinaryContract
onCreate: (m: challengeInfo) => Promise<void>
challengeSlug: string
}) {
const { user, onCreate, contract, challengeSlug } = props
const [isCreating, setIsCreating] = useState(false)
const [finishedCreating, setFinishedCreating] = useState(false)
const [error, setError] = useState<string>('')
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
const defaultExpire = 'week'
const defaultMessage = `${user.name} is challenging you to a bet! Do you think ${contract.question}`
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
expiresTime: dayjs().add(2, defaultExpire).valueOf(),
outcome: 'YES',
amount: 100,
acceptorAmount: 100,
message: defaultMessage,
})
useEffect(() => {
setError('')
}, [challengeInfo])
return (
<>
{!finishedCreating && (
<form
onSubmit={(e) => {
e.preventDefault()
if (user.balance < challengeInfo.amount) {
setError('You do not have enough mana to create this challenge')
return
}
setIsCreating(true)
onCreate(challengeInfo).finally(() => setIsCreating(false))
setFinishedCreating(true)
}}
>
<Title className="!mt-2" text="Challenge bet " />
<div className="mb-8">
Challenge a friend to bet on{' '}
<span className="underline">{contract.question}</span>
</div>
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2">
<div>You'll bet:</div>
<Row
className={
'form-control w-full max-w-xs items-center justify-between gap-4 pr-3'
}
>
<AmountInput
amount={challengeInfo.amount || undefined}
onChange={(newAmount) =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
amount: newAmount ?? 0,
acceptorAmount: editingAcceptorAmount
? m.acceptorAmount
: newAmount ?? 0,
}
})
}
error={undefined}
label={'M$'}
inputClassName="w-24"
/>
<span className={''}>on</span>
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
</Row>
<Row className={'mt-3 max-w-xs justify-end'}>
<Button
color={'gray-white'}
onClick={() =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
outcome: m.outcome === 'YES' ? 'NO' : 'YES',
}
})
}
>
<SwitchVerticalIcon className={'h-6 w-6'} />
</Button>
</Row>
<Row className={'items-center'}>If they bet:</Row>
<Row className={'max-w-xs items-center justify-between gap-4 pr-3'}>
<div className={'w-32 sm:mr-1'}>
<AmountInput
amount={challengeInfo.acceptorAmount || undefined}
onChange={(newAmount) => {
setEditingAcceptorAmount(true)
setChallengeInfo((m: challengeInfo) => {
return {
...m,
acceptorAmount: newAmount ?? 0,
}
})
}}
error={undefined}
label={'M$'}
inputClassName="w-24"
/>
</div>
<span>on</span>
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
</Row>
</div>
<Button
size="2xs"
color="gray"
onClick={() => {
setEditingAcceptorAmount(true)
const p = getProbability(contract)
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
const { amount } = challengeInfo
const acceptorAmount = Math.round(amount / prob - amount)
setChallengeInfo({ ...challengeInfo, acceptorAmount })
}}
>
Use market odds
</Button>
<div className="mt-8">
If the challenge is accepted, whoever is right will earn{' '}
<span className="font-semibold">
{formatMoney(
challengeInfo.acceptorAmount + challengeInfo.amount || 0
)}
</span>{' '}
in total.
</div>
<Row className="mt-8 items-center">
<Button
type="submit"
color={'gradient'}
size="xl"
className={clsx(
'whitespace-nowrap drop-shadow-md',
isCreating ? 'disabled' : ''
)}
>
Create challenge bet
</Button>
</Row>
<Row className={'text-error'}>{error} </Row>
</form>
)}
{finishedCreating && (
<>
<Title className="!my-0" text="Challenge Created!" />
<div>Share the challenge using the link.</div>
<button
onClick={() => {
copyToClipboard(challengeSlug)
toast('Link copied to clipboard!')
}}
className={'btn btn-outline mb-4 whitespace-nowrap normal-case'}
>
<LinkIcon className={'mr-2 h-5 w-5'} />
Copy link
</button>
<QRCode url={challengeSlug} className="self-center" />
<Row className={'gap-1 text-gray-500'}>
See your other
<SiteLink className={'underline'} href={'/challenges'}>
challenges
</SiteLink>
</Row>
</>
)}
</>
)
}

View File

@ -22,7 +22,10 @@ export function ChoicesToggleGroup(props: {
} = props
return (
<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()}
onChange={setChoice}
>

View File

@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'
import { Comment } from 'common/comment'
import { Contract } from 'common/contract'
import { contractPath } from 'web/lib/firebase/contracts'
import { groupConsecutive } from 'common/util/array'
import { getUsersComments } from 'web/lib/firebase/comments'
import { SiteLink } from './site-link'
import { Row } from './layout/row'
import { Avatar } from './avatar'
@ -8,49 +10,82 @@ import { RelativeTimestamp } from './relative-timestamp'
import { UserLink } from './user-page'
import { User } from 'common/user'
import { Col } from './layout/col'
import { Linkify } from './linkify'
import { groupBy } from 'lodash'
import { Content } from './editor'
import { Pagination } from './pagination'
import { LoadingIndicator } from './loading-indicator'
export function UserCommentsList(props: {
user: User
comments: Comment[]
contractsById: { [id: string]: Contract }
}) {
const { comments, contractsById } = props
const COMMENTS_PER_PAGE = 50
// we don't show comments in groups here atm, just comments on contracts
const contractComments = comments.filter((c) => c.contractId)
const commentsByContract = groupBy(contractComments, 'contractId')
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
setComments(cs.filter((c) => c.contractId) as ContractComment[])
})
}, [user.id])
if (comments == null) {
return <LoadingIndicator />
}
const pageComments = groupConsecutive(comments.slice(start, end), (c) => {
return { question: c.contractQuestion, slug: c.contractSlug }
})
return (
<Col className={'bg-white'}>
{Object.entries(commentsByContract).map(([contractId, comments]) => {
const contract = contractsById[contractId]
{pageComments.map(({ key, items }, i) => {
return (
<div key={contractId} className={'border-width-1 border-b p-5'}>
<div key={start + i} className="border-b p-5">
<SiteLink
className={'mb-2 block text-sm text-indigo-700'}
href={contractPath(contract)}
className="mb-2 block pb-2 font-medium text-indigo-700"
href={contractPath(key.slug)}
>
{contract.question}
{key.question}
</SiteLink>
{comments.map((comment) => (
<ProfileComment
key={comment.id}
comment={comment}
className="relative flex items-start space-x-3 pb-6"
/>
))}
<Col className="gap-6">
{items.map((comment) => (
<ProfileComment
key={comment.id}
comment={comment}
className="relative flex items-start space-x-3"
/>
))}
</Col>
</div>
)
})}
<Pagination
page={page}
itemsPerPage={COMMENTS_PER_PAGE}
totalItems={comments.length}
setPage={setPage}
/>
</Col>
)
}
function ProfileComment(props: { comment: Comment; className?: string }) {
const { comment, className } = props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment
// TODO: find and attach relevant bets by comment betId at some point
return (
<Row className={className}>
@ -64,7 +99,7 @@ function ProfileComment(props: { comment: Comment; className?: string }) {
/>{' '}
<RelativeTimestamp time={createdTime} />
</p>
<Linkify text={text} />
<Content content={content || text} smallImage />
</div>
</Row>
)

View File

@ -1,26 +1,43 @@
/* 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 { 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 {
ContractHighlightOptions,
ContractsGrid,
} from './contract/contracts-list'
} from './contract/contracts-grid'
import { ShowTime } from './contract/contract-details'
import { Row } from './layout/row'
import { useEffect, useMemo, useState } from 'react'
import { Spacer } from './layout/spacer'
import { useEffect, useRef, useMemo, useState } from 'react'
import { unstable_batchedUpdates } from 'react-dom'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useUser } from 'web/hooks/use-user'
import { useFollows } from 'web/hooks/use-follows'
import { track, trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
import { useMemberGroups } from 'web/hooks/use-group'
import { Group, NEW_USER_GROUP_SLUGS } from 'common/group'
import { NEW_USER_GROUP_SLUGS } from 'common/group'
import { PillButton } from './buttons/pill-button'
import { range, sortBy } from 'lodash'
import { debounce, sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
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(
'GJQPAYENIF',
@ -40,44 +57,176 @@ const sortOptions = [
{ label: 'Close date', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' },
]
export const DEFAULT_SORT = 'score'
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
type SearchParameters = {
index: SearchIndex
query: string
numericFilters: SearchOptions['numericFilters']
facetFilters: SearchOptions['facetFilters']
showTime?: ShowTime
}
type AdditionalFilter = {
creatorId?: string
tag?: string
excludeContractIds?: string[]
groupSlug?: string
}
export function ContractSearch(props: {
querySortOptions?: {
defaultSort: Sort
defaultFilter?: filter
shouldLoadFromStorage?: boolean
}
additionalFilter?: {
creatorId?: string
tag?: string
excludeContractIds?: string[]
groupSlug?: string
}
user?: User | null
defaultSort?: Sort
defaultFilter?: filter
additionalFilter?: AdditionalFilter
highlightOptions?: ContractHighlightOptions
onContractClick?: (contract: Contract) => void
showPlaceHolder?: boolean
hideOrderSelector?: boolean
overrideGridClassName?: string
cardHideOptions?: {
hideGroupLink?: boolean
hideQuickBet?: boolean
}
headerClassName?: string
useQuerySortLocalStorage?: boolean
useQuerySortUrlParams?: boolean
}) {
const {
querySortOptions,
user,
defaultSort,
defaultFilter,
additionalFilter,
onContractClick,
overrideGridClassName,
hideOrderSelector,
showPlaceHolder,
cardHideOptions,
highlightOptions,
headerClassName,
useQuerySortLocalStorage,
useQuerySortUrlParams,
} = 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(
(group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
)
@ -91,31 +240,8 @@ export function ContractSearch(props: {
(group) => group.contractIds.length
).reverse()
const defaultPillGroups = DEFAULT_CATEGORY_GROUPS as Group[]
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 pillGroups: { name: string; slug: string }[] =
memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS
const additionalFilters = [
additionalFilter?.creatorId
@ -162,6 +288,27 @@ export function ContractSearch(props: {
filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
].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 index = useMemo(() => searchClient.initIndex(indexName), [indexName])
const searchIndex = useMemo(
@ -169,100 +316,28 @@ export function ContractSearch(props: {
[searchIndexName]
)
const [page, setPage] = useState(0)
const [numPages, setNumPages] = useState(1)
const [hitsByPage, setHitsByPage] = useState<{ [page: string]: Contract[] }>(
{}
)
useEffect(() => {
let wasMostRecentQuery = true
const algoliaIndex = query ? searchIndex : index
algoliaIndex
.search(query, {
facetFilters,
numericFilters,
page,
hitsPerPage: 20,
})
.then((results) => {
if (!wasMostRecentQuery) return
if (page === 0) {
setHitsByPage({
[0]: results.hits as any as Contract[],
})
} else {
setHitsByPage((hitsByPage) => ({
...hitsByPage,
[page]: results.hits,
}))
}
setNumPages(results.nbPages)
})
return () => {
wasMostRecentQuery = false
}
// Note numeric filters are unique based on current time, so can't compare
// them by value.
}, [query, page, index, searchIndex, JSON.stringify(facetFilters), filter])
const loadMore = () => {
if (page >= numPages - 1) return
const haveLoadedCurrentPage = hitsByPage[page]
if (haveLoadedCurrentPage) setPage(page + 1)
}
const hits = range(0, page + 1)
.map((p) => hitsByPage[p] ?? [])
.flat()
const contracts = hits.filter(
(c) => !additionalFilter?.excludeContractIds?.includes(c.id)
)
const showTime =
sort === 'close-date' || sort === 'resolve-date' ? sort : undefined
const updateQuery = (newQuery: string) => {
setQuery(newQuery)
setPage(0)
}
const selectFilter = (newFilter: filter) => {
if (newFilter === filter) return
setFilter(newFilter)
setPage(0)
trackCallback('select search filter', { filter: newFilter })
}
const selectSort = (newSort: Sort) => {
if (newSort === sort) return
setPage(0)
setSort(newSort)
track('select sort', { sort: newSort })
}
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return (
<ContractSearchFirestore
querySortOptions={querySortOptions}
additionalFilter={additionalFilter}
/>
)
}
onSearchParametersChanged({
index: query ? searchIndex : index,
query: query,
numericFilters: numericFilters,
facetFilters: facetFilters,
showTime:
sort === 'close-date' || sort === 'resolve-date' ? sort : undefined,
})
}, [query, index, searchIndex, filter, JSON.stringify(facetFilters)])
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">
<input
type="text"
value={query}
onChange={(e) => updateQuery(e.target.value)}
placeholder={showPlaceHolder ? `Search ${filter} markets` : ''}
onBlur={trackCallback('search', { query })}
placeholder={'Search'}
className="input input-bordered w-full"
/>
{!query && (
@ -292,9 +367,7 @@ export function ContractSearch(props: {
)}
</Row>
<Spacer h={3} />
{pillsEnabled && (
{!additionalFilter && !query && (
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton
key={'all'}
@ -334,25 +407,6 @@ export function ContractSearch(props: {
})}
</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>
)
}

View File

@ -0,0 +1,44 @@
import { Contract } from 'common/contract'
import { getBinaryProbPercent } from 'web/lib/firebase/contracts'
import { richTextToString } from 'common/util/parse'
import { contractTextDetails } from 'web/components/contract/contract-details'
import { getFormattedMappedValue } from 'common/pseudo-numeric'
import { getProbability } from 'common/calculate'
export const getOpenGraphProps = (contract: Contract) => {
const {
resolution,
question,
creatorName,
creatorUsername,
outcomeType,
creatorAvatarUrl,
description: desc,
} = contract
const probPercent =
outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined
const numericValue =
outcomeType === 'PSEUDO_NUMERIC'
? getFormattedMappedValue(contract)(getProbability(contract))
: undefined
const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc)
const description = resolution
? `Resolved ${resolution}. ${stringDesc}`
: probPercent
? `${probPercent} chance. ${stringDesc}`
: stringDesc
return {
question,
probability: probPercent,
metadata: contractTextDetails(contract),
creatorName,
creatorUsername,
creatorAvatarUrl,
description,
numericValue,
}
}

View File

@ -31,6 +31,7 @@ import { useUser } from 'web/hooks/use-user'
import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics'
import { getMappedValue } from 'common/pseudo-numeric'
import { Tooltip } from '../tooltip'
export function ContractCard(props: {
contract: Contract
@ -65,114 +66,118 @@ export function ContractCard(props: {
!hideQuickBet
return (
<div>
<Col
<Row
className={clsx(
'relative gap-3 self-start rounded-lg bg-white shadow-md hover:cursor-pointer hover:bg-gray-100',
className
)}
>
<Col className="group relative flex-1 gap-3 py-4 pb-12 pl-6">
{onClick ? (
<a
className="absolute top-0 left-0 right-0 bottom-0"
href={contractPath(contract)}
onClick={(e) => {
// Let the browser handle the link click (opens in new tab).
if (e.ctrlKey || e.metaKey) return
e.preventDefault()
track('click market card', {
slug: contract.slug,
contractId: contract.id,
})
onClick()
}}
/>
) : (
<Link href={contractPath(contract)}>
<a
onClick={trackCallback('click market card', {
slug: contract.slug,
contractId: contract.id,
})}
className="absolute top-0 left-0 right-0 bottom-0"
/>
</Link>
)}
<AvatarDetails
contract={contract}
className={'hidden md:inline-flex'}
/>
<p
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' }}
>
{question}
</p>
{(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') &&
(resolution ? (
<FreeResponseOutcomeLabel
contract={contract}
resolution={resolution}
truncate={'long'}
/>
) : (
<FreeResponseTopAnswer contract={contract} truncate="long" />
))}
</Col>
{showQuickBet ? (
<QuickBet contract={contract} user={user} />
) : (
<>
{outcomeType === 'BINARY' && (
<BinaryResolutionOrChance
className="items-center self-center pr-5"
contract={contract}
/>
)}
{outcomeType === 'PSEUDO_NUMERIC' && (
<PseudoNumericResolutionOrExpectation
className="items-center self-center pr-5"
contract={contract}
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
className="items-center self-center pr-5"
contract={contract}
/>
)}
{(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<FreeResponseResolutionOrChance
className="items-center self-center pr-5 text-gray-600"
contract={contract}
truncate="long"
/>
)}
<ProbBar contract={contract} />
</>
)}
<Row
className={clsx(
'relative gap-3 rounded-lg bg-white py-4 pl-6 pr-5 shadow-md hover:cursor-pointer hover:bg-gray-100',
className
'absolute bottom-3 gap-2 truncate px-5 md:gap-0',
showQuickBet ? 'w-[85%]' : 'w-full'
)}
>
<Row>
<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 ? (
<a
className="absolute top-0 left-0 right-0 bottom-0"
href={contractPath(contract)}
onClick={(e) => {
// Let the browser handle the link click (opens in new tab).
if (e.ctrlKey || e.metaKey) return
e.preventDefault()
track('click market card', {
slug: contract.slug,
contractId: contract.id,
})
onClick()
}}
/>
) : (
<Link href={contractPath(contract)}>
<a
onClick={trackCallback('click market card', {
slug: contract.slug,
contractId: contract.id,
})}
className="absolute top-0 left-0 right-0 bottom-0"
/>
</Link>
)}
</div>
<AvatarDetails contract={contract} />
<p
className="break-words font-semibold text-indigo-700 peer-hover:underline peer-hover:decoration-indigo-400 peer-hover:decoration-2"
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
>
{question}
</p>
{(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') &&
(resolution ? (
<FreeResponseOutcomeLabel
contract={contract}
resolution={resolution}
truncate={'long'}
/>
) : (
<FreeResponseTopAnswer contract={contract} truncate="long" />
))}
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showTime={showTime}
hideGroupLink={hideGroupLink}
/>
</Col>
{showQuickBet ? (
<QuickBet contract={contract} user={user} />
) : (
<Col className="m-auto pl-2">
{outcomeType === 'BINARY' && (
<BinaryResolutionOrChance
className="items-center"
contract={contract}
/>
)}
{outcomeType === 'PSEUDO_NUMERIC' && (
<PseudoNumericResolutionOrExpectation
className="items-center"
contract={contract}
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
className="items-center"
contract={contract}
/>
)}
{(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<FreeResponseResolutionOrChance
className="self-end text-gray-600"
contract={contract}
truncate="long"
/>
)}
<ProbBar contract={contract} />
</Col>
)}
</Row>
</Col>
</div>
<AvatarDetails
contract={contract}
short={true}
className={'block md:hidden'}
/>
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showTime={showTime}
hideGroupLink={hideGroupLink}
/>
</Row>
</Row>
)
}
@ -332,22 +337,19 @@ export function PseudoNumericResolutionOrExpectation(props: {
{resolution === 'CANCEL' ? (
<CancelLabel />
) : (
<div
className={clsx('tooltip', textColor)}
data-tip={value.toFixed(2)}
>
<Tooltip className={textColor} text={value.toFixed(2)}>
{formatLargeNumber(value)}
</div>
</Tooltip>
)}
</>
) : (
<>
<div
className={clsx('tooltip text-3xl', textColor)}
data-tip={value.toFixed(2)}
<Tooltip
className={clsx('text-3xl', textColor)}
text={value.toFixed(2)}
>
{formatLargeNumber(value)}
</div>
</Tooltip>
<div className={clsx('text-base', textColor)}>expected</div>
</>
)}

View File

@ -13,6 +13,7 @@ import { TextEditor, useTextEditor } from 'web/components/editor'
import { Button } from '../button'
import { Spacer } from '../layout/spacer'
import { Editor, Content as ContentType } from '@tiptap/react'
import { insertContent } from '../editor/utils'
export function ContractDescription(props: {
contract: Contract
@ -94,12 +95,8 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {
size="xs"
onClick={() => {
setEditing(true)
editor
?.chain()
.setContent(contract.description)
.focus('end')
.insertContent(`<p>${editTimestamp()}</p>`)
.run()
editor?.commands.focus('end')
insertContent(editor, `<p>${editTimestamp()}</p>`)
}}
>
Edit description
@ -131,7 +128,7 @@ function EditQuestion(props: {
function joinContent(oldContent: ContentType, newContent: string) {
const editor = new Editor({ content: oldContent, extensions: exhibitExts })
editor.chain().focus('end').insertContent(newContent).run()
insertContent(editor, newContent)
return editor.getJSON()
}

View File

@ -5,13 +5,13 @@ import {
TrendingUpIcon,
UserGroupIcon,
} from '@heroicons/react/outline'
import { Row } from '../layout/row'
import { formatMoney } from 'common/util/format'
import { UserLink } from '../user-page'
import {
Contract,
contractMetrics,
contractPath,
updateContract,
} from 'web/lib/firebase/contracts'
import dayjs from 'dayjs'
@ -24,17 +24,17 @@ import { Bet } from 'common/bet'
import NewContractBadge from '../new-contract-badge'
import { UserFollowButton } from '../follow-button'
import { DAY_MS } from 'common/util/time'
import { ShareIconButton } from 'web/components/share-icon-button'
import { useUser } from 'web/hooks/use-user'
import { Editor } from '@tiptap/react'
import { exhibitExts } from 'common/util/parse'
import { ENV_CONFIG } from 'common/envs/constants'
import { Button } from 'web/components/button'
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
import { SiteLink } from 'web/components/site-link'
import { groupPath } from 'web/lib/firebase/groups'
import { insertContent } from '../editor/utils'
import clsx from 'clsx'
export type ShowTime = 'resolve-date' | 'close-date'
@ -58,13 +58,13 @@ export function MiscDetails(props: {
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
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 ? (
<Row className="gap-0.5">
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
</Row>
) : showTime === 'close-date' ? (
<Row className="gap-0.5">
<Row className="gap-0.5 whitespace-nowrap">
<ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
{fromNow(closeTime || 0)}
@ -84,30 +84,33 @@ export function MiscDetails(props: {
{!hideGroupLink && groupLinks && groupLinks.length > 0 && (
<SiteLink
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}
</Row>
{groupLinks[0].name}
</SiteLink>
)}
</Row>
)
}
export function AvatarDetails(props: { contract: Contract }) {
const { contract } = props
export function AvatarDetails(props: {
contract: Contract
className?: string
short?: boolean
}) {
const { contract, short, className } = props
const { creatorName, creatorUsername } = contract
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
username={creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
size={6}
/>
<UserLink name={creatorName} username={creatorUsername} />
<UserLink name={creatorName} username={creatorUsername} short={short} />
</Row>
)
}
@ -147,6 +150,15 @@ export function ContractDetails(props: {
const user = useUser()
const [open, setOpen] = useState(false)
const groupInfo = (
<Row>
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
<span className="truncate">
{groupToDisplay ? groupToDisplay.name : 'No group'}
</span>
</Row>
)
return (
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
<Row className="items-center gap-2">
@ -168,19 +180,18 @@ export function ContractDetails(props: {
{!disabled && <UserFollowButton userId={creatorId} small />}
</Row>
<Row>
<Button
size={'xs'}
className={'max-w-[200px]'}
color={'gray-white'}
onClick={() => setOpen(!open)}
>
<Row>
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
<span className={'line-clamp-1'}>
{groupToDisplay ? groupToDisplay.name : 'No group'}
</span>
</Row>
</Button>
{disabled ? (
groupInfo
) : (
<Button
size={'xs'}
className={'max-w-[200px]'}
color={'gray-white'}
onClick={() => setOpen(!open)}
>
{groupInfo}
</Button>
)}
</Row>
<Modal open={open} setOpen={setOpen} size={'md'}>
<Col
@ -204,7 +215,7 @@ export function ContractDetails(props: {
<>
<DateTimeTooltip
text="Market resolved:"
time={contract.resolutionTime}
time={dayjs(contract.resolutionTime)}
>
{resolvedDate}
</DateTimeTooltip>
@ -228,14 +239,6 @@ export function ContractDetails(props: {
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
<ShareIconButton
copyPayload={`https://${ENV_CONFIG.domain}${contractPath(contract)}${
user?.username && contract.creatorUsername !== user?.username
? '?referrer=' + user?.username
: ''
}`}
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
/>
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
</Row>
@ -268,13 +271,16 @@ function EditableCloseDate(props: {
}) {
const { closeTime, contract, isCreator } = props
const dayJsCloseTime = dayjs(closeTime)
const dayJsNow = dayjs()
const [isEditingCloseTime, setIsEditingCloseTime] = useState(false)
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 isSameDay = dayjs(closeTime).isSame(dayjs(), 'day')
const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year')
const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day')
const onSave = () => {
const newCloseTime = dayjs(closeDate).valueOf()
@ -284,12 +290,11 @@ function EditableCloseDate(props: {
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
const editor = new Editor({ content, extensions: exhibitExts })
editor
.chain()
.focus('end')
.insertContent('<br /><br />')
.insertContent(`Close date updated to ${formattedCloseDate}`)
.run()
editor.commands.focus('end')
insertContent(
editor,
`<br><p>Close date updated to ${formattedCloseDate}</p>`
)
updateContract(contract.id, {
closeTime: newCloseTime,
@ -316,11 +321,11 @@ function EditableCloseDate(props: {
) : (
<DateTimeTooltip
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
time={closeTime}
time={dayJsCloseTime}
>
{isSameYear
? dayjs(closeTime).format('MMM D')
: dayjs(closeTime).format('MMM D, YYYY')}
? dayJsCloseTime.format('MMM D')
: dayJsCloseTime.format('MMM D, YYYY')}
{isSameDay && <> ({fromNow(closeTime)})</>}
</DateTimeTooltip>
)}

View File

@ -7,16 +7,12 @@ import { Bet } from 'common/bet'
import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { contractPath, contractPool } from 'web/lib/firebase/contracts'
import { contractPool } from 'web/lib/firebase/contracts'
import { LiquidityPanel } from '../liquidity-panel'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Row } from '../layout/row'
import { ShareEmbedButton } from '../share-embed-button'
import { Title } from '../title'
import { TweetButton } from '../tweet-button'
import { InfoTooltip } from '../info-tooltip'
import { DuplicateContractButton } from '../copy-contract-button'
export const contractDetailsButtonClassName =
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
@ -61,20 +57,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
<Col className="gap-4 rounded bg-white p-6">
<Title className="!mt-0 !mb-0" text="Market info" />
<div>Share</div>
<Row className="justify-start gap-4">
<TweetButton
className="self-start"
tweetText={getTweetText(contract)}
/>
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
<DuplicateContractButton contract={contract} />
</Row>
<div />
<div>Stats</div>
<table className="table-compact table-zebra table w-full text-gray-500">
<tbody>
<tr>
@ -150,14 +132,3 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
</>
)
}
const getTweetText = (contract: Contract) => {
const { question, resolution } = contract
const tweetDescription = resolution ? `\n\nResolved ${resolution}!` : ''
const timeParam = `${Date.now()}`.substring(7)
const url = `https://manifold.markets${contractPath(contract)}?t=${timeParam}`
return `${question}\n\n${url}${tweetDescription}`
}

View File

@ -107,7 +107,6 @@ export function ContractTopTrades(props: {
comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
truncate={false}
smallAvatar={false}
/>
</div>

View File

@ -1,3 +1,6 @@
import React from 'react'
import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col'
import { Spacer } from '../layout/spacer'
@ -5,11 +8,9 @@ import { ContractProbGraph } from './contract-prob-graph'
import { useUser } from 'web/hooks/use-user'
import { Row } from '../layout/row'
import { Linkify } from '../linkify'
import clsx from 'clsx'
import {
FreeResponseResolutionOrChance,
BinaryResolutionOrChance,
FreeResponseResolutionOrChance,
NumericResolutionOrExpectation,
PseudoNumericResolutionOrExpectation,
} from './contract-card'
@ -19,8 +20,8 @@ import { AnswersGraph } from '../answers/answers-graph'
import { Contract, CPMMBinaryContract } from 'common/contract'
import { ContractDescription } from './contract-description'
import { ContractDetails } from './contract-details'
import { ShareMarket } from '../share-market'
import { NumericGraph } from './numeric-graph'
import { ShareRow } from './share-row'
export const ContractOverview = (props: {
contract: Contract
@ -32,6 +33,7 @@ export const ContractOverview = (props: {
const user = useUser()
const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
@ -116,8 +118,7 @@ export const ContractOverview = (props: {
<AnswersGraph contract={contract} bets={bets} />
)}
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}
{(contract.description || isCreator) && <Spacer h={6} />}
{isCreator && <ShareMarket className="px-2" contract={contract} />}
<ShareRow user={user} contract={contract} />
<ContractDescription
className="px-2"
contract={contract}

View File

@ -8,18 +8,17 @@ import { Spacer } from '../layout/spacer'
import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision'
import { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity'
export function ContractTabs(props: {
contract: Contract
user: User | null | undefined
bets: Bet[]
liquidityProvisions: LiquidityProvision[]
comments: Comment[]
tips: CommentTipMap
}) {
const { contract, user, bets, tips, liquidityProvisions } = props
const { contract, user, bets, tips } = props
const { outcomeType } = contract
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
)
const liquidityProvisions =
useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? []
// Load comments here, so the badge count will be correct
const updatedComments = useComments(contract.id)
const comments = updatedComments ?? props.comments

View File

@ -5,9 +5,10 @@ import { SiteLink } from '../site-link'
import { ContractCard } from './contract-card'
import { ShowTime } from './contract-details'
import { ContractSearch } from '../contract-search'
import { useIsVisible } from 'web/hooks/use-is-visible'
import { useEffect, useState } from 'react'
import { useCallback } from 'react'
import clsx from 'clsx'
import { LoadingIndicator } from '../loading-indicator'
import { VisibilityObserver } from '../visibility-observer'
export type ContractHighlightOptions = {
contractIds?: string[]
@ -15,9 +16,8 @@ export type ContractHighlightOptions = {
}
export function ContractsGrid(props: {
contracts: Contract[]
loadMore: () => void
hasMore: boolean
contracts: Contract[] | undefined
loadMore?: () => void
showTime?: ShowTime
onContractClick?: (contract: Contract) => void
overrideGridClassName?: string
@ -30,7 +30,6 @@ export function ContractsGrid(props: {
const {
contracts,
showTime,
hasMore,
loadMore,
onContractClick,
overrideGridClassName,
@ -38,16 +37,19 @@ export function ContractsGrid(props: {
highlightOptions,
} = props
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
const { contractIds, highlightClassName } = highlightOptions || {}
const [elem, setElem] = useState<HTMLElement | null>(null)
const isBottomVisible = useIsVisible(elem)
const onVisibilityUpdated = useCallback(
(visible) => {
if (visible && loadMore) {
loadMore()
}
},
[loadMore]
)
useEffect(() => {
if (isBottomVisible && hasMore) {
loadMore()
}
}, [isBottomVisible, hasMore, loadMore])
if (contracts === undefined) {
return <LoadingIndicator />
}
if (contracts.length === 0) {
return (
@ -87,21 +89,25 @@ export function ContractsGrid(props: {
/>
))}
</ul>
<div ref={setElem} className="relative -top-96 h-1" />
<VisibilityObserver
onVisibilityUpdated={onVisibilityUpdated}
className="relative -top-96 h-1"
/>
</Col>
)
}
export function CreatorContractsList(props: { creator: User }) {
const { creator } = props
export function CreatorContractsList(props: {
user: User | null | undefined
creator: User
}) {
const { user, creator } = props
return (
<ContractSearch
querySortOptions={{
defaultSort: 'newest',
defaultFilter: 'all',
shouldLoadFromStorage: false,
}}
user={user}
defaultSort="newest"
defaultFilter="all"
additionalFilter={{
creatorId: creator.id,
}}

View File

@ -138,7 +138,7 @@ export function QuickBet(props: {
return (
<Col
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
// `bg-opacity-10 bg-${color}`
)}
@ -319,7 +319,7 @@ function getProb(contract: Contract) {
? getBinaryProb(contract)
: outcomeType === 'PSEUDO_NUMERIC'
? getProbability(contract)
: outcomeType === 'FREE_RESPONSE'
: outcomeType === 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE'
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
: outcomeType === 'NUMERIC'
? getNumericScale(contract)

View File

@ -0,0 +1,82 @@
import { LinkIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { Contract } from 'common/contract'
import { contractPath } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Row } from '../layout/row'
import { ShareEmbedButton } from '../share-embed-button'
import { Title } from '../title'
import { TweetButton } from '../tweet-button'
import { DuplicateContractButton } from '../copy-contract-button'
import { Button } from '../button'
import { copyToClipboard } from 'web/lib/util/copy'
import { track } from 'web/lib/service/analytics'
import { ENV_CONFIG } from 'common/envs/constants'
import { REFERRAL_AMOUNT, User } from 'common/user'
import { SiteLink } from '../site-link'
import { formatMoney } from 'common/util/format'
export function ShareModal(props: {
contract: Contract
user: User | undefined | null
isOpen: boolean
setOpen: (open: boolean) => void
}) {
const { contract, user, isOpen, setOpen } = props
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
const shareUrl = `https://${ENV_CONFIG.domain}${contractPath(contract)}${
user?.username && contract.creatorUsername !== user?.username
? '?referrer=' + user?.username
: ''
}`
return (
<Modal open={isOpen} setOpen={setOpen} size="md">
<Col className="gap-4 rounded bg-white p-4">
<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
size="2xl"
color="gradient"
className={'mb-2 flex max-w-xs self-center'}
onClick={() => {
copyToClipboard(shareUrl)
toast.success('Link copied!', {
icon: linkIcon,
})
track('copy share link')
}}
>
{linkIcon} Copy link
</Button>
<Row className="z-0 justify-start gap-4 self-center">
<TweetButton
className="self-start"
tweetText={getTweetText(contract, shareUrl)}
/>
<ShareEmbedButton contract={contract} />
<DuplicateContractButton contract={contract} />
</Row>
</Col>
</Modal>
)
}
const getTweetText = (contract: Contract, url: string) => {
const { question, resolution } = contract
const tweetDescription = resolution ? `\n\nResolved ${resolution}!` : ''
return `${question}\n\n${url}${tweetDescription}`
}

View File

@ -0,0 +1,67 @@
import clsx from 'clsx'
import { ShareIcon } from '@heroicons/react/outline'
import { Row } from '../layout/row'
import { Contract } from 'web/lib/firebase/contracts'
import { useState } from 'react'
import { Button } from 'web/components/button'
import { CreateChallengeModal } from '../challenges/create-challenge-modal'
import { User } from 'common/user'
import { CHALLENGES_ENABLED } from 'common/challenge'
import { ShareModal } from './share-modal'
import { withTracking } from 'web/lib/service/analytics'
export function ShareRow(props: {
contract: Contract
user: User | undefined | null
}) {
const { user, contract } = props
const { outcomeType, resolution } = contract
const showChallenge =
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
const [isOpen, setIsOpen] = useState(false)
const [isShareOpen, setShareOpen] = useState(false)
return (
<Row className="mt-2">
<Button
size="lg"
color="gray-white"
className={'flex'}
onClick={() => {
setShareOpen(true)
}}
>
<ShareIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />
Share
<ShareModal
isOpen={isShareOpen}
setOpen={setShareOpen}
contract={contract}
user={user}
/>
</Button>
{showChallenge && (
<Button
size="lg"
color="gray-white"
onClick={withTracking(
() => setIsOpen(true),
'click challenge button'
)}
>
Challenge
<CreateChallengeModal
isOpen={isOpen}
setOpen={setIsOpen}
user={user}
contract={contract}
/>
</Button>
)}
</Row>
)
}

View File

@ -2,7 +2,6 @@ import React, { Fragment } from 'react'
import { LinkIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react'
import clsx from 'clsx'
import { copyToClipboard } from 'web/lib/util/copy'
import { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics'
@ -14,6 +13,8 @@ export function CopyLinkButton(props: {
tracking?: string
buttonClassName?: string
toastClassName?: string
icon?: React.ComponentType<{ className?: string }>
label?: string
}) {
const { url, displayUrl, tracking, buttonClassName, toastClassName } = props

View File

@ -1,35 +1,28 @@
import React from 'react'
import dayjs from 'dayjs'
import dayjs, { Dayjs } from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import advanced from 'dayjs/plugin/advancedFormat'
import { ClientRender } from './client-render'
import { Tooltip } from './tooltip'
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(advanced)
export function DateTimeTooltip(props: {
time: number
time: Dayjs
text?: string
className?: string
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
return (
<>
<ClientRender>
<span
className="tooltip hidden cursor-default sm:inline-block"
data-tip={toolTip}
>
{props.children}
</span>
</ClientRender>
<span className="whitespace-nowrap sm:hidden">{props.children}</span>
</>
<Tooltip className={className} text={toolTip} noTap={noTap}>
{props.children}
</Tooltip>
)
}

View File

@ -3,7 +3,6 @@ import Placeholder from '@tiptap/extension-placeholder'
import {
useEditor,
EditorContent,
FloatingMenu,
JSONContent,
Content,
Editor,
@ -11,28 +10,42 @@ import {
import StarterKit from '@tiptap/starter-kit'
import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention'
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import { Linkify } from './linkify'
import { uploadImage } from 'web/lib/firebase/storage'
import { useMutation } from 'react-query'
import { exhibitExts } from 'common/util/parse'
import { FileUploadButton } from './file-upload-button'
import { linkClass } from './site-link'
import { useUsers } from 'web/hooks/use-users'
import { mentionSuggestion } from './editor/mention-suggestion'
import { DisplayMention } from './editor/mention'
import Iframe from 'common/util/tiptap-iframe'
import { CodeIcon, PhotographIcon } from '@heroicons/react/solid'
import { Modal } from './layout/modal'
import { Col } from './layout/col'
import { Button } from './button'
import { Row } from './layout/row'
import { Spacer } from './layout/spacer'
import TiptapTweet from './editor/tiptap-tweet'
import { EmbedModal } from './editor/embed-modal'
import {
CodeIcon,
PhotographIcon,
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(
'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'
)
@ -41,14 +54,16 @@ export function useTextEditor(props: {
max?: number
defaultValue?: Content
disabled?: boolean
simple?: boolean
}) {
const { placeholder, max, defaultValue = '', disabled } = props
const { placeholder, max, defaultValue = '', disabled, simple } = props
const users = useUsers()
const editorClass = clsx(
proseClass,
'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0'
!simple && 'min-h-[6em]',
'outline-none pt-2 px-4'
)
const editor = useEditor(
@ -56,24 +71,22 @@ export function useTextEditor(props: {
editorProps: { attributes: { class: editorClass } },
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
heading: simple ? false : { levels: [1, 2, 3] },
horizontalRule: simple ? false : {},
}),
Placeholder.configure({
placeholder,
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 }),
Image,
Link.configure({
HTMLAttributes: {
class: clsx('no-underline !text-indigo-700', linkClass),
},
}),
simple ? DisplayImage : Image,
DisplayLink,
DisplayMention.configure({
suggestion: mentionSuggestion(users),
}),
Iframe,
TiptapTweet,
],
content: defaultValue,
},
@ -97,7 +110,7 @@ export function useTextEditor(props: {
// If the pasted content is iframe code, directly inject it
const text = event.clipboardData?.getData('text/plain').trim() ?? ''
if (isValidIframe(text)) {
editor.chain().insertContent(text).run()
insertContent(editor, text)
return true // Prevent the code from getting pasted as text
}
@ -120,67 +133,68 @@ function isValidIframe(text: string) {
export function TextEditor(props: {
editor: Editor | null
upload: ReturnType<typeof useUploadMutation>
children?: React.ReactNode // additional toolbar buttons
}) {
const { editor, upload } = props
const { editor, upload, children } = props
const [iframeOpen, setIframeOpen] = useState(false)
const [marketOpen, setMarketOpen] = useState(false)
return (
<>
{/* hide placeholder when focused */}
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
{editor && (
<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">
<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">
<EditorContent editor={editor} />
{/* Spacer element to match the height of the toolbar */}
<div className="py-2" aria-hidden="true">
{/* Matches height of button in toolbar (1px border + 36px content height) */}
<div className="py-px">
<div className="h-9" />
</div>
</div>
</div>
{/* Toolbar, with buttons for image and embeds */}
<div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2">
<div className="flex items-center space-x-5">
<div className="flex items-center">
{/* Toolbar, with buttons for images and embeds */}
<div className="flex h-9 items-center gap-5 pl-4 pr-1">
<Tooltip className="flex items-center" text="Add image" noTap>
<FileUploadButton
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"
>
<PhotographIcon className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">Upload an image</span>
</FileUploadButton>
</div>
<div className="flex items-center">
</Tooltip>
<Tooltip className="flex items-center" text="Add embed" noTap>
<button
type="button"
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"
>
<IframeModal
<EmbedModal
editor={editor}
open={iframeOpen}
setOpen={setIframeOpen}
/>
<CodeIcon className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">Embed an iframe</span>
</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>
@ -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) =>
useMutation(
(files: File[]) =>
@ -269,14 +224,20 @@ const useUploadMutation = (editor: Editor | null) =>
}
)
function RichContent(props: { content: JSONContent | string }) {
const { content } = props
export function RichContent(props: {
content: JSONContent | string
smallImage?: boolean
}) {
const { content, smallImage } = props
const editor = useEditor({
editorProps: { attributes: { class: proseClass } },
extensions: [
// replace tiptap's Mention with ours, to add style and link
...exhibitExts.filter((ex) => ex.name !== Mention.name),
StarterKit,
smallImage ? DisplayImage : Image,
DisplayLink,
DisplayMention,
Iframe,
TiptapTweet,
],
content,
editable: false,
@ -287,13 +248,16 @@ function RichContent(props: { content: JSONContent | string }) {
}
// 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
return typeof content === 'string' ? (
<div className="whitespace-pre-line font-light leading-relaxed">
<Linkify text={content} />
</div>
) : (
<RichContent content={content} />
<RichContent {...props} />
)
}

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

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

View File

@ -11,7 +11,7 @@ const name = 'mention-component'
const MentionComponent = (props: any) => {
return (
<NodeViewWrapper className={clsx(name, 'not-prose inline text-indigo-700')}>
<NodeViewWrapper className={clsx(name, 'not-prose text-indigo-700')}>
<Linkify text={'@' + props.node.attrs.label} />
</NodeViewWrapper>
)
@ -25,5 +25,6 @@ const MentionComponent = (props: any) => {
export const DisplayMention = Mention.extend({
parseHTML: () => [{ tag: name }],
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
addNodeView: () => ReactNodeViewRenderer(MentionComponent),
addNodeView: () =>
ReactNodeViewRenderer(MentionComponent, { className: 'inline-block' }),
})

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

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

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

View File

@ -26,7 +26,10 @@ export function ContractActivity(props: {
const contract = useContractWithPreload(props.contract) ?? props.contract
const comments = props.comments
const updatedBets = useBets(contract.id)
const updatedBets = useBets(contract.id, {
filterChallenges: false,
filterRedemptions: true,
})
const bets = (updatedBets ?? props.bets).filter(
(bet) => !bet.isRedemption && bet.amount !== 0
)

View File

@ -7,6 +7,7 @@ import { fromNow } from 'web/lib/util/time'
import { ToastClipboard } from 'web/components/toast-clipboard'
import { LinkIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import dayjs from 'dayjs'
export function CopyLinkDateTimeComponent(props: {
prefix: string
@ -17,6 +18,7 @@ export function CopyLinkDateTimeComponent(props: {
}) {
const { prefix, slug, elementId, createdTime, className } = props
const [showToast, setShowToast] = useState(false)
const time = dayjs(createdTime)
function copyLinkToComment(
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
@ -30,7 +32,7 @@ export function CopyLinkDateTimeComponent(props: {
}
return (
<div className={clsx('inline', className)}>
<DateTimeTooltip time={createdTime}>
<DateTimeTooltip time={time} noTap>
<Link href={`/${prefix}/${slug}#${elementId}`} passHref={true}>
<a
onClick={(event) => copyLinkToComment(event)}

View File

@ -31,9 +31,9 @@ export function FeedAnswerCommentGroup(props: {
const { answer, contract, comments, tips, bets, user } = props
const { username, avatarUrl, name, text } = answer
const [replyToUsername, setReplyToUsername] = useState('')
const [replyToUser, setReplyToUser] =
useState<Pick<User, 'id' | 'username'>>()
const [showReply, setShowReply] = useState(false)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
const [highlighted, setHighlighted] = useState(false)
const router = useRouter()
@ -70,9 +70,14 @@ export function FeedAnswerCommentGroup(props: {
const scrollAndOpenReplyInput = useEvent(
(comment?: Comment, answer?: Answer) => {
setReplyToUsername(comment?.userUsername ?? answer?.username ?? '')
setReplyToUser(
comment
? { id: comment.userId, username: comment.userUsername }
: answer
? { id: answer.userId, username: answer.username }
: undefined
)
setShowReply(true)
inputRef?.focus()
}
)
@ -80,7 +85,7 @@ export function FeedAnswerCommentGroup(props: {
// Only show one comment input for a bet at a time
if (
betsByCurrentUser.length > 1 &&
inputRef?.textContent?.length === 0 &&
// inputRef?.textContent?.length === 0 && //TODO: editor.isEmpty
betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0]
?.outcome !== answer.number.toString()
)
@ -89,10 +94,6 @@ export function FeedAnswerCommentGroup(props: {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [betsByCurrentUser.length, user, answer.number])
useEffect(() => {
if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply])
useEffect(() => {
if (router.asPath.endsWith(`#${answerElementId}`)) {
setHighlighted(true)
@ -154,7 +155,6 @@ export function FeedAnswerCommentGroup(props: {
commentsList={commentsList}
betsByUserId={betsByUserId}
smallAvatar={true}
truncate={false}
bets={bets}
tips={tips}
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
@ -172,12 +172,8 @@ export function FeedAnswerCommentGroup(props: {
betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={commentsByCurrentUser}
parentAnswerOutcome={answer.number.toString()}
replyToUsername={replyToUsername}
setRef={setInputRef}
onSubmitComment={() => {
setShowReply(false)
setReplyToUsername('')
}}
replyToUser={replyToUser}
onSubmitComment={() => setShowReply(false)}
/>
</div>
)}

View File

@ -10,11 +10,14 @@ import { UsersIcon } from '@heroicons/react/solid'
import { formatMoney, formatPercent } from 'common/util/format'
import { OutcomeLabel } from 'web/components/outcome-label'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
import React, { Fragment } from 'react'
import React, { Fragment, useEffect } from 'react'
import { uniqBy, partition, sumBy, groupBy } from 'lodash'
import { JoinSpans } from 'web/components/join-spans'
import { UserLink } from '../user-page'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { SiteLink } from 'web/components/site-link'
import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
import { Challenge } from 'common/challenge'
export function FeedBet(props: {
contract: Contract
@ -33,38 +36,33 @@ export function FeedBet(props: {
const isSelf = user?.id === userId
return (
<>
<Row className={'flex w-full gap-2 pt-3'}>
{isSelf ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={user.avatarUrl}
username={user.username}
/>
) : bettor ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={bettor.avatarUrl}
username={bettor.username}
/>
) : (
<div className="relative px-1">
<EmptyAvatar />
</div>
)}
<div className={'min-w-0 flex-1 py-1.5'}>
<BetStatusText
bet={bet}
contract={contract}
isSelf={isSelf}
bettor={bettor}
hideOutcome={hideOutcome}
/>
</div>
</Row>
</>
<Row className={'flex w-full items-center gap-2 pt-3'}>
{isSelf ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={user.avatarUrl}
username={user.username}
/>
) : bettor ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={bettor.avatarUrl}
username={bettor.username}
/>
) : (
<EmptyAvatar className="mx-1" />
)}
<BetStatusText
bet={bet}
contract={contract}
isSelf={isSelf}
bettor={bettor}
hideOutcome={hideOutcome}
className="flex-1"
/>
</Row>
)
}
@ -74,12 +72,21 @@ export function BetStatusText(props: {
isSelf: boolean
bettor?: User
hideOutcome?: boolean
className?: string
}) {
const { bet, contract, bettor, isSelf, hideOutcome } = props
const { bet, contract, bettor, isSelf, hideOutcome, className } = props
const { outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const isFreeResponse = outcomeType === 'FREE_RESPONSE'
const { amount, outcome, createdTime } = bet
const { amount, outcome, createdTime, challengeSlug } = bet
const [challenge, setChallenge] = React.useState<Challenge>()
useEffect(() => {
if (challengeSlug) {
getChallenge(challengeSlug, contract.id).then((c) => {
setChallenge(c)
})
}
}, [challengeSlug, contract.id])
const bought = amount >= 0 ? 'bought' : 'sold'
const outOfTotalAmount =
@ -112,7 +119,7 @@ export function BetStatusText(props: {
: formatPercent(bet.limitProb ?? bet.probAfter)
return (
<div className="text-sm text-gray-500">
<div className={clsx('text-sm text-gray-500', className)}>
{bettor ? (
<UserLink name={bettor.name} username={bettor.username} />
) : (
@ -133,6 +140,14 @@ export function BetStatusText(props: {
{fromProb === toProb
? `at ${fromProb}`
: `from ${fromProb} to ${toProb}`}
{challengeSlug && (
<SiteLink
href={challenge ? getChallengeUrl(challenge) : ''}
className={'mx-1'}
>
[challenge]
</SiteLink>
)}
</>
)}
<RelativeTimestamp time={createdTime} />

View File

@ -13,25 +13,22 @@ import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page'
import { OutcomeLabel } from 'web/components/outcome-label'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { contractPath } from 'web/lib/firebase/contracts'
import { firebaseLogin } from 'web/lib/firebase/users'
import {
createCommentOnContract,
MAX_COMMENT_LENGTH,
} from 'web/lib/firebase/comments'
import Textarea from 'react-expanding-textarea'
import { Linkify } from 'web/components/linkify'
import { SiteLink } from 'web/components/site-link'
import { BetStatusText } from 'web/components/feed/feed-bets'
import { Col } from 'web/components/layout/col'
import { getProbability } from 'common/calculate'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { PaperAirplaneIcon } from '@heroicons/react/outline'
import { track } from 'web/lib/service/analytics'
import { useEvent } from 'web/hooks/use-event'
import { Tipper } from '../tipper'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Content, TextEditor, useTextEditor } from '../editor'
import { Editor } from '@tiptap/react'
export function FeedCommentThread(props: {
contract: Contract
@ -39,20 +36,12 @@ export function FeedCommentThread(props: {
tips: CommentTipMap
parentComment: Comment
bets: Bet[]
truncate?: boolean
smallAvatar?: boolean
}) {
const {
contract,
comments,
bets,
tips,
truncate,
smallAvatar,
parentComment,
} = props
const { contract, comments, bets, tips, smallAvatar, parentComment } = props
const [showReply, setShowReply] = useState(false)
const [replyToUsername, setReplyToUsername] = useState('')
const [replyToUser, setReplyToUser] =
useState<{ id: string; username: string }>()
const betsByUserId = groupBy(bets, (bet) => bet.userId)
const user = useUser()
const commentsList = comments.filter(
@ -60,15 +49,12 @@ export function FeedCommentThread(props: {
parentComment.id && comment.replyToCommentId === parentComment.id
)
commentsList.unshift(parentComment)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
function scrollAndOpenReplyInput(comment: Comment) {
setReplyToUsername(comment.userUsername)
setReplyToUser({ id: comment.userId, username: comment.userUsername })
setShowReply(true)
inputRef?.focus()
}
useEffect(() => {
if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply])
return (
<Col className={'w-full gap-3 pr-1'}>
<span
@ -81,7 +67,6 @@ export function FeedCommentThread(props: {
betsByUserId={betsByUserId}
tips={tips}
smallAvatar={smallAvatar}
truncate={truncate}
bets={bets}
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
/>
@ -98,13 +83,9 @@ export function FeedCommentThread(props: {
(c) => c.userId === user?.id
)}
parentCommentId={parentComment.id}
replyToUsername={replyToUsername}
replyToUser={replyToUser}
parentAnswerOutcome={comments[0].answerOutcome}
setRef={setInputRef}
onSubmitComment={() => {
setShowReply(false)
setReplyToUsername('')
}}
onSubmitComment={() => setShowReply(false)}
/>
</Col>
)}
@ -121,14 +102,12 @@ export function CommentRepliesList(props: {
bets: Bet[]
treatFirstIndexEqually?: boolean
smallAvatar?: boolean
truncate?: boolean
}) {
const {
contract,
commentsList,
betsByUserId,
tips,
truncate,
smallAvatar,
bets,
scrollAndOpenReplyInput,
@ -168,7 +147,6 @@ export function CommentRepliesList(props: {
: undefined
}
smallAvatar={smallAvatar}
truncate={truncate}
/>
</div>
))}
@ -182,7 +160,6 @@ export function FeedComment(props: {
tips: CommentTips
betsBySameUser: Bet[]
probAtCreatedTime?: number
truncate?: boolean
smallAvatar?: boolean
onReplyClick?: (comment: Comment) => void
}) {
@ -192,10 +169,10 @@ export function FeedComment(props: {
tips,
betsBySameUser,
probAtCreatedTime,
truncate,
onReplyClick,
} = props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment
let betOutcome: string | undefined,
bought: string | undefined,
money: string | undefined
@ -276,11 +253,9 @@ export function FeedComment(props: {
elementId={comment.id}
/>
</div>
<TruncatedComment
comment={text}
moreHref={contractPath(contract)}
shouldTruncate={truncate}
/>
<div className="mt-2 text-[15px] text-gray-700">
<Content content={content || text} smallImage />
</div>
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
<Tipper comment={comment} tips={tips ?? {}} />
{onReplyClick && (
@ -345,8 +320,7 @@ export function CommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
commentsByCurrentUser: Comment[]
replyToUsername?: string
setRef?: (ref: HTMLTextAreaElement) => void
replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
@ -359,12 +333,18 @@ export function CommentInput(props: {
commentsByCurrentUser,
parentAnswerOutcome,
parentCommentId,
replyToUsername,
replyToUser,
onSubmitComment,
setRef,
} = props
const user = useUser()
const [comment, setComment] = useState('')
const { editor, upload } = useTextEditor({
simple: true,
max: MAX_COMMENT_LENGTH,
placeholder:
!!parentCommentId || !!parentAnswerOutcome
? 'Write a reply...'
: 'Write a comment...',
})
const [isSubmitting, setIsSubmitting] = useState(false)
const mostRecentCommentableBet = getMostRecentCommentableBet(
@ -380,18 +360,17 @@ export function CommentInput(props: {
track('sign in to comment')
return await firebaseLogin()
}
if (!comment || isSubmitting) return
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
await createCommentOnContract(
contract.id,
comment,
editor.getJSON(),
user,
betId,
parentAnswerOutcome,
parentCommentId
)
onSubmitComment?.()
setComment('')
setIsSubmitting(false)
}
@ -415,8 +394,8 @@ export function CommentInput(props: {
/>
</div>
<div className={'min-w-0 flex-1'}>
<div className="pl-0.5 text-sm text-gray-500">
<div className={'mb-1'}>
<div className="pl-0.5 text-sm">
<div className="mb-1 text-gray-500">
{mostRecentCommentableBet && (
<BetStatusText
contract={contract}
@ -446,14 +425,12 @@ export function CommentInput(props: {
)}
</div>
<CommentInputTextArea
commentText={comment}
setComment={setComment}
isReply={!!parentCommentId || !!parentAnswerOutcome}
replyToUsername={replyToUsername ?? ''}
editor={editor}
upload={upload}
replyToUser={replyToUser}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
setRef={setRef}
presetId={id}
/>
</div>
@ -465,94 +442,93 @@ export function CommentInput(props: {
export function CommentInputTextArea(props: {
user: User | undefined | null
isReply: boolean
replyToUsername: string
commentText: string
setComment: (text: string) => void
replyToUser?: { id: string; username: string }
editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
isSubmitting: boolean
setRef?: (ref: HTMLTextAreaElement) => void
submitOnEnter?: boolean
presetId?: string
enterToSubmitOnDesktop?: boolean
}) {
const {
isReply,
setRef,
user,
commentText,
setComment,
editor,
upload,
submitComment,
presetId,
isSubmitting,
replyToUsername,
enterToSubmitOnDesktop,
submitOnEnter,
replyToUser,
} = props
const { width } = useWindowSize()
const memoizedSetComment = useEvent(setComment)
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
useEffect(() => {
if (!replyToUsername || !user || replyToUsername === user.username) return
const replacement = `@${replyToUsername} `
memoizedSetComment(replacement + commentText.replace(replacement, ''))
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
const submit = () => {
submitComment(presetId)
editor?.commands?.clearContent()
}
useEffect(() => {
if (!editor) {
return
}
// submit on Enter key
editor.setOptions({
editorProps: {
handleKeyDown: (view, event) => {
if (
submitOnEnter &&
event.key === 'Enter' &&
!event.shiftKey &&
(!isMobile || event.ctrlKey || event.metaKey) &&
// mention list is closed
!(view.state as any).mention$.active
) {
submit()
event.preventDefault()
return true
}
return false
},
},
})
// insert at mention and focus
if (replyToUser) {
editor
.chain()
.setContent({
type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id },
})
.insertContent(' ')
.focus()
.run()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, replyToUsername, memoizedSetComment])
}, [editor])
return (
<>
<Row className="gap-1.5 text-gray-700">
<Textarea
ref={setRef}
value={commentText}
onChange={(e) => setComment(e.target.value)}
className={clsx('textarea textarea-bordered w-full resize-none')}
// Make room for floating submit button.
style={{ paddingRight: 48 }}
placeholder={
isReply
? 'Write a reply... '
: enterToSubmitOnDesktop
? 'Send a message'
: 'Write a comment...'
}
autoFocus={false}
maxLength={MAX_COMMENT_LENGTH}
disabled={isSubmitting}
onKeyDown={(e) => {
if (
(enterToSubmitOnDesktop &&
e.key === 'Enter' &&
!e.shiftKey &&
width &&
width > 768) ||
(e.key === 'Enter' && (e.ctrlKey || e.metaKey))
) {
e.preventDefault()
submitComment(presetId)
e.currentTarget.blur()
}
}}
/>
<Col className={clsx('relative justify-end')}>
<div>
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className={clsx(
'btn btn-ghost btn-sm absolute right-2 bottom-2 flex-row pl-2 capitalize',
!commentText && 'pointer-events-none text-gray-500'
)}
onClick={() => {
submitComment(presetId)
}}
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
<PaperAirplaneIcon
className={'m-0 min-w-[22px] rotate-90 p-0 '}
height={25}
/>
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</Col>
</Row>
</TextEditor>
</div>
<Row>
{!user && (
<button
@ -567,38 +543,6 @@ export function CommentInputTextArea(props: {
)
}
export function TruncatedComment(props: {
comment: string
moreHref: string
shouldTruncate?: boolean
}) {
const { comment, moreHref, shouldTruncate } = props
let truncated = comment
// Keep descriptions to at most 400 characters
const MAX_CHARS = 400
if (shouldTruncate && truncated.length > MAX_CHARS) {
truncated = truncated.slice(0, MAX_CHARS)
// Make sure to end on a space
const i = truncated.lastIndexOf(' ')
truncated = truncated.slice(0, i)
}
return (
<div
className="mt-2 whitespace-pre-line break-words text-gray-700"
style={{ fontSize: 15 }}
>
<Linkify text={truncated} />
{truncated != comment && (
<SiteLink href={moreHref} className="text-indigo-700">
... (show more)
</SiteLink>
)}
</div>
)
}
function getBettorsLargestPositionBeforeTime(
contract: Contract,
createdTime: number,

View File

@ -1,5 +1,5 @@
// From https://tailwindui.com/components/application-ui/lists/feeds
import React, { useState } from 'react'
import React from 'react'
import {
BanIcon,
CheckIcon,
@ -22,7 +22,6 @@ import { UserLink } from '../user-page'
import BetRow from '../bet-row'
import { Avatar } from '../avatar'
import { ActivityItem } from './activity-items'
import { useSaveSeenContract } from 'web/hooks/use-seen-contracts'
import { useUser } from 'web/hooks/use-user'
import { trackClick } from 'web/lib/firebase/tracking'
import { DAY_MS } from 'common/util/time'
@ -50,11 +49,8 @@ export function FeedItems(props: {
const { contract, items, className, betRowClassName, user } = props
const { outcomeType } = contract
const [elem, setElem] = useState<HTMLElement | null>(null)
useSaveSeenContract(elem, contract)
return (
<div className={clsx('flow-root', className)} ref={setElem}>
<div className={clsx('flow-root', className)}>
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
{items.map((item, activityItemIdx) => (
<div key={item.id} className={'relative pb-4'}>

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

View File

@ -5,27 +5,23 @@ import React, { useEffect, memo, useState, useMemo } from 'react'
import { Avatar } from 'web/components/avatar'
import { Group } from 'common/group'
import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments'
import {
CommentInputTextArea,
TruncatedComment,
} from 'web/components/feed/feed-comments'
import { CommentInputTextArea } from 'web/components/feed/feed-comments'
import { track } from 'web/lib/service/analytics'
import { firebaseLogin } from 'web/lib/firebase/users'
import { useRouter } from 'next/router'
import clsx from 'clsx'
import { UserLink } from 'web/components/user-page'
import { groupPath } from 'web/lib/firebase/groups'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { Tipper } from 'web/components/tipper'
import { sum } from 'lodash'
import { formatMoney } from 'common/util/format'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Content, useTextEditor } from 'web/components/editor'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { ChatIcon, ChevronDownIcon } from '@heroicons/react/outline'
import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline'
import { setNotificationsAsSeen } from 'web/pages/notifications'
import { usePrivateUser } from 'web/hooks/use-user'
export function GroupChat(props: {
messages: Comment[]
@ -34,16 +30,21 @@ export function GroupChat(props: {
tips: CommentTipMap
}) {
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 [scrollToBottomRef, setScrollToBottomRef] =
useState<HTMLDivElement | null>(null)
const [scrollToMessageId, setScrollToMessageId] = useState('')
const [scrollToMessageRef, setScrollToMessageRef] =
useState<HTMLDivElement | null>(null)
const [replyToUsername, setReplyToUsername] = useState('')
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
const [groupedMessages, setGroupedMessages] = useState<Comment[]>([])
const [replyToUser, setReplyToUser] = useState<any>()
const router = useRouter()
const isMember = user && group.memberIds.includes(user?.id)
@ -54,25 +55,26 @@ export function GroupChat(props: {
const remainingHeight =
(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.
const tempMessages = []
const tempGrouped: Comment[][] = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (i === 0) tempMessages.push({ ...message })
if (i === 0) tempGrouped.push([message])
else {
const prevMessage = messages[i - 1]
const diff = message.createdTime - prevMessage.createdTime
const creatorsMatch = message.userId === prevMessage.userId
if (diff < 2 * 60 * 1000 && creatorsMatch) {
tempMessages[tempMessages.length - 1].text += `\n${message.text}`
tempGrouped.at(-1)?.push(message)
} else {
tempMessages.push({ ...message })
tempGrouped.push([message])
}
}
}
setGroupedMessages(tempMessages)
return tempGrouped
}, [messages])
useEffect(() => {
@ -94,11 +96,12 @@ export function GroupChat(props: {
useEffect(() => {
// is mobile?
if (inputRef && width && width > 720) inputRef.focus()
}, [inputRef, width])
if (width && width > 720) focusInput()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [width])
function onReplyClick(comment: Comment) {
setReplyToUsername(comment.userUsername)
setReplyToUser({ id: comment.userId, username: comment.userUsername })
}
async function submitMessage() {
@ -106,13 +109,16 @@ export function GroupChat(props: {
track('sign in to comment')
return await firebaseLogin()
}
if (!messageText || isSubmitting) return
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
await createCommentOnGroup(group.id, messageText, user)
setMessageText('')
await createCommentOnGroup(group.id, editor.getJSON(), user)
editor.commands.clearContent()
setIsSubmitting(false)
setReplyToUsername('')
inputRef?.focus()
setReplyToUser(undefined)
focusInput()
}
function focusInput() {
editor?.commands.focus()
}
return (
@ -123,20 +129,20 @@ export function GroupChat(props: {
}
ref={setScrollToBottomRef}
>
{groupedMessages.map((message) => (
{groupedMessages.map((messages) => (
<GroupMessage
user={user}
key={message.id}
comment={message}
key={`group ${messages[0].id}`}
comments={messages}
group={group}
onReplyClick={onReplyClick}
highlight={message.id === scrollToMessageId}
highlight={messages[0].id === scrollToMessageId}
setRef={
scrollToMessageId === message.id
scrollToMessageId === messages[0].id
? setScrollToMessageRef
: undefined
}
tips={tips[message.id] ?? {}}
tips={tips[messages[0].id] ?? {}}
/>
))}
{messages.length === 0 && (
@ -144,7 +150,7 @@ export function GroupChat(props: {
No messages yet. Why not{isMember ? ` ` : ' join and '}
<button
className={'cursor-pointer font-bold text-gray-700'}
onClick={() => inputRef?.focus()}
onClick={focusInput}
>
add one?
</button>
@ -162,19 +168,26 @@ export function GroupChat(props: {
</div>
<div className={'flex-1'}>
<CommentInputTextArea
commentText={messageText}
setComment={setMessageText}
isReply={false}
editor={editor}
upload={upload}
user={user}
replyToUsername={replyToUsername}
replyToUser={replyToUser}
submitComment={submitMessage}
isSubmitting={isSubmitting}
enterToSubmitOnDesktop={true}
setRef={setInputRef}
submitOnEnter
/>
</div>
</div>
)}
{privateUser && (
<GroupChatNotificationsIcon
group={group}
privateUser={privateUser}
shouldSetAsSeen={true}
hidden={true}
/>
)}
</Col>
)
}
@ -239,7 +252,7 @@ export function GroupChatInBubble(props: {
}}
>
{!shouldShowChat ? (
<ChatIcon className="h-10 w-10" aria-hidden="true" />
<UsersIcon className="h-10 w-10" aria-hidden="true" />
) : (
<ChevronDownIcon className={'h-10 w-10'} aria-hidden={'true'} />
)}
@ -248,6 +261,7 @@ export function GroupChatInBubble(props: {
group={group}
privateUser={privateUser}
shouldSetAsSeen={shouldShowChat}
hidden={false}
/>
)}
</button>
@ -259,8 +273,9 @@ function GroupChatNotificationsIcon(props: {
group: Group
privateUser: PrivateUser
shouldSetAsSeen: boolean
hidden: boolean
}) {
const { privateUser, group, shouldSetAsSeen } = props
const { privateUser, group, shouldSetAsSeen, hidden } = props
const preferredNotificationsForThisGroup = useUnseenPreferredNotifications(
privateUser,
{
@ -282,7 +297,9 @@ function GroupChatNotificationsIcon(props: {
return (
<div
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'
: 'hidden'
}
@ -292,16 +309,18 @@ function GroupChatNotificationsIcon(props: {
const GroupMessage = memo(function GroupMessage_(props: {
user: User | null | undefined
comment: Comment
comments: Comment[]
group: Group
onReplyClick?: (comment: Comment) => void
setRef?: (ref: HTMLDivElement) => void
highlight?: boolean
tips: CommentTips
}) {
const { comment, onReplyClick, group, setRef, highlight, user, tips } = props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
const isCreatorsComment = user && comment.userId === user.id
const { comments, onReplyClick, group, setRef, highlight, user, tips } = props
const first = comments[0]
const { id, userUsername, userName, userAvatarUrl, createdTime } = first
const isCreatorsComment = user && first.userId === user.id
return (
<Col
ref={setRef}
@ -331,23 +350,25 @@ const GroupMessage = memo(function GroupMessage_(props: {
prefix={'group'}
slug={group.slug}
createdTime={createdTime}
elementId={comment.id}
/>
</Row>
<Row className={'text-black'}>
<TruncatedComment
comment={text}
moreHref={groupPath(group.slug)}
shouldTruncate={false}
elementId={id}
/>
</Row>
<div className="mt-2 text-base text-black">
{comments.map((comment) => (
<Content
key={comment.id}
content={comment.content || comment.text}
smallImage
/>
))}
</div>
<Row>
{!isCreatorsComment && onReplyClick && (
<button
className={
'self-start py-1 text-xs font-bold text-gray-500 hover:underline'
}
onClick={() => onReplyClick(comment)}
onClick={() => onReplyClick(first)}
>
Reply
</button>
@ -357,7 +378,7 @@ const GroupMessage = memo(function GroupMessage_(props: {
{formatMoney(sum(Object.values(tips)))}
</span>
)}
{!isCreatorsComment && <Tipper comment={comment} tips={tips} />}
{!isCreatorsComment && <Tipper comment={first} tips={tips} />}
</Row>
</Col>
)

View File

@ -1,10 +1,11 @@
import { InformationCircleIcon } from '@heroicons/react/outline'
import { Tooltip } from './tooltip'
export function InfoTooltip(props: { text: string }) {
const { text } = props
return (
<div className="tooltip" data-tip={text}>
<Tooltip text={text}>
<InformationCircleIcon className="h-5 w-5 text-gray-500" />
</div>
</Tooltip>
)
}

View File

@ -4,7 +4,7 @@ import { Contract } from 'common/contract'
import { Spacer } from './layout/spacer'
import { firebaseLogin } from 'web/lib/firebase/users'
import { ContractsGrid } from './contract/contracts-list'
import { ContractsGrid } from './contract/contracts-grid'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { withTracking } from 'web/lib/service/analytics'
@ -59,11 +59,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
<SparklesIcon className="inline h-5 w-5" aria-hidden="true" />
Trending markets
</Row>
<ContractsGrid
contracts={hotContracts?.slice(0, 10) || []}
loadMore={() => {}}
hasMore={false}
/>
<ContractsGrid contracts={hotContracts?.slice(0, 10) || []} />
</>
)
}

View File

@ -28,7 +28,6 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
className,
currentPageForAnalytics,
} = props
const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case
return (
<>
<nav
@ -64,7 +63,11 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
</a>
))}
</nav>
{activeTab?.content}
{tabs.map((tab, i) => (
<div key={i} className={i === activeIndex ? 'block' : 'hidden'}>
{tab.content}
</div>
))}
</>
)
}

View File

@ -11,7 +11,6 @@ import { useUserLiquidity } from 'web/hooks/use-liquidity'
import { Tabs } from './layout/tabs'
import { NoLabel, YesLabel } from './outcome-label'
import { Col } from './layout/col'
import { InfoTooltip } from './info-tooltip'
import { track } from 'web/lib/service/analytics'
export function LiquidityPanel(props: { contract: CPMMContract }) {
@ -103,8 +102,7 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
return (
<>
<div className="align-center mb-4 text-gray-500">
Subsidize this market by adding M$ to the liquidity pool.{' '}
<InfoTooltip text="The greater the M$ subsidy, the greater the incentive for traders to participate, the more accurate the market will be." />
Subsidize this market by adding M$ to the liquidity pool.
</div>
<Row>
@ -114,6 +112,7 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
label="M$"
error={error}
disabled={isLoading}
inputClassName="w-28"
/>
<button
className={clsx('btn btn-primary ml-2', isLoading && 'btn-disabled')}

View File

@ -44,7 +44,7 @@ export function BottomNavBar() {
const currentPage = router.pathname
const user = useUser()
const privateUser = usePrivateUser(user?.id)
const privateUser = usePrivateUser()
const isIframe = useIsIframe()
if (isIframe) {

View File

@ -29,6 +29,8 @@ import { Spacer } from '../layout/spacer'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { PrivateUser } from 'common/user'
import { useWindowSize } from 'web/hooks/use-window-size'
import { CHALLENGES_ENABLED } from 'common/challenge'
import { buildArray } from 'common/util/array'
const logout = async () => {
// log out, and then reload the page, in case SSR wants to boot them out
@ -60,26 +62,40 @@ function getMoreNavigation(user?: User | null) {
}
if (!user) {
return [
{ name: 'Charity', href: '/charity' },
{ name: 'Blog', href: 'https://news.manifold.markets' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
]
return buildArray(
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[
{ name: 'Charity', href: '/charity' },
{
name: 'Salem tournament',
href: 'https://salemcenter.manifold.markets/',
},
{ name: 'Blog', href: 'https://news.manifold.markets' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
]
)
}
return [
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
{
name: 'Sign out',
href: '#',
onClick: logout,
},
]
return buildArray(
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{
name: 'Salem tournament',
href: 'https://salemcenter.manifold.markets/',
},
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
{
name: 'Sign out',
href: '#',
onClick: logout,
},
]
)
}
const signedOutNavigation = [
@ -116,21 +132,27 @@ const signedInMobileNavigation = [
]
function getMoreMobileNav() {
return [
...(IS_PRIVATE_MANIFOLD
? []
: [
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
]),
{
name: 'Sign out',
href: '#',
onClick: logout,
},
]
const signOut = {
name: 'Sign out',
href: '#',
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 = {
@ -199,7 +221,7 @@ export default function Sidebar(props: { className?: string }) {
const currentPage = router.pathname
const user = useUser()
const privateUser = usePrivateUser(user?.id)
const privateUser = usePrivateUser()
// usePing(user?.id)
const navigationOptions = !user ? signedOutNavigation : getNavigation()
@ -295,8 +317,7 @@ function GroupsList(props: {
const { height } = useWindowSize()
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const remainingHeight =
(height ?? window.innerHeight) - (containerRef?.offsetTop ?? 0)
const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0)
const notifIsForThisItem = useMemo(
() => (itemHref: string) =>

View File

@ -2,15 +2,14 @@ import { BellIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { Row } from 'web/components/layout/row'
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 { useUnseenPreferredNotificationGroups } from 'web/hooks/use-notifications'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
import { PrivateUser } from 'common/user'
export default function NotificationsIcon(props: { className?: string }) {
const user = useUser()
const privateUser = usePrivateUser(user?.id)
const privateUser = usePrivateUser()
return (
<Row className={clsx('justify-center')}>

View File

@ -1,5 +1,4 @@
import clsx from 'clsx'
import { ReactNode } from 'react'
import { Answer } from 'common/answer'
import { getProbability } from 'common/calculate'
import { getValueFromBucket } from 'common/calculate-dpm'
@ -11,7 +10,7 @@ import {
resolution,
} from 'common/contract'
import { formatLargeNumber, formatPercent } from 'common/util/format'
import { ClientRender } from './client-render'
import { Tooltip } from './tooltip'
export function OutcomeLabel(props: {
contract: Contract
@ -91,13 +90,13 @@ export function FreeResponseOutcomeLabel(props: {
const chosen = contract.answers?.find((answer) => answer.id === resolution)
if (!chosen) return <AnswerNumberLabel number={resolution} />
return (
<FreeResponseAnswerToolTip text={chosen.text}>
<Tooltip text={chosen.text}>
<AnswerLabel
answer={chosen}
truncate={truncate}
className={answerClassName}
/>
</FreeResponseAnswerToolTip>
</Tooltip>
)
}
@ -174,23 +173,3 @@ export function AnswerLabel(props: {
</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>
</>
)
}

View File

@ -61,7 +61,8 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
min: Math.min(...points.map((p) => p.y)),
}}
gridYValues={numYTickValues}
curve="monotoneX"
curve="stepAfter"
enablePoints={false}
colors={{ datum: 'color' }}
axisBottom={{
tickValues: numXTickValues,

Some files were not shown because too many files have changed in this diff Show More