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({
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;">

View File

@ -1,11 +1,9 @@
<!DOCTYPE html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>(no subject)</title>
<title>Manifold Market Creation Guide</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
@ -15,18 +13,21 @@
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
@ -35,6 +36,7 @@
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
@ -58,21 +60,9 @@
</style>
<![endif]-->
<!--[if !mso]><!-->
<link
href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700"
rel="stylesheet"
type="text/css"
/>
<link
href="https://fonts.googleapis.com/css?family=Readex+Pro"
rel="stylesheet"
type="text/css"
/>
<link
href="https://fonts.googleapis.com/css?family=Readex+Pro"
rel="stylesheet"
type="text/css"
/>
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
@ -104,35 +94,28 @@
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing: normal; background-color: #f4f4f4">
<div style="background-color: #f4f4f4">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div
style="
<div style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 600px;
"
>
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%"
>
">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%">
<tbody>
<tr>
<td
style="
<td style="
direction: ltr;
font-size: 0px;
padding: 0px 0px 0px 0px;
@ -141,33 +124,21 @@
padding-right: 0px;
padding-top: 0px;
text-align: center;
"
>
">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
<div class="mj-column-per-100 mj-outlook-group-fix" style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align: top"
width="100%">
<tbody>
<tr>
<td
align="center"
style="
<td align="center" style="
font-size: 0px;
padding: 0px 25px 0px 25px;
padding-top: 0px;
@ -175,29 +146,16 @@
padding-bottom: 0px;
padding-left: 25px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="
border-collapse: collapse;
border-spacing: 0px;
"
>
">
<tbody>
<tr>
<td style="width: 550px">
<a
href="https://manifold.markets/home"
target="_blank"
><img
alt=""
height="auto"
src="https://03jlj.mjt.lu/img/03jlj/b/96u/omk8.gif"
style="
<a href="https://manifold.markets/home" target="_blank"><img alt="" height="auto"
src="https://03jlj.mjt.lu/img/03jlj/b/96u/omk8.gif" style="
border: none;
display: block;
outline: none;
@ -205,9 +163,7 @@
height: auto;
width: 100%;
font-size: 13px;
"
width="550"
/></a>
" width="550" /></a>
</td>
</tr>
</tbody>
@ -224,26 +180,17 @@
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div
style="
<div style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 600px;
"
>
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%"
>
">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%">
<tbody>
<tr>
<td
style="
<td style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 0px 0px;
@ -252,33 +199,34 @@
padding-right: 0px;
padding-top: 20px;
text-align: center;
"
>
">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
<div class="mj-column-per-100 mj-outlook-group-fix" style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align: top"
width="100%">
<tbody>
<tr>
<td
align="left"
style="
<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;">
Hi {{name}},</span></p>
</div>
</td>
</tr>
<tr>
<td align="left" style="
font-size: 0px;
padding: 0px 25px 20px 25px;
padding-top: 0px;
@ -286,311 +234,204 @@
padding-bottom: 20px;
padding-left: 25px;
word-break: break-word;
"
>
<div
style="
">
<div style="
font-family: Arial, sans-serif;
font-size: 17px;
letter-spacing: normal;
line-height: 1;
text-align: left;
color: #000000;
"
>
<p
class="text-build-content"
style="
">
<p class="text-build-content" style="
line-height: 23px;
margin: 10px 0;
margin-top: 10px;
"
data-testid="3Q8BP69fq"
>
<span
style="
" data-testid="3Q8BP69fq">
<span style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>On Manifold Markets, several important factors
go into making a good question. These lead to
more people betting on them and allowing a more
accurate prediction to be formed!</span
>
">Congrats on creating your first market on <a class="link-build-content"
style="color: #55575d" target="_blank"
href="https://manifold.markets">Manifold</a>!</span>
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
&nbsp;
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq">
<span style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>Manifold also gives its creators 10 Mana for
each unique trader that bets on your
market!</span
>
">The following is a short guide to creating markets.</span>
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq">
&nbsp;
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq">
<span style="
color: #292fd7;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 20px;
"
><b>What makes a good question?</b></span
>
"><b>What makes a good market?</b></span>
</p>
<ul>
<li style="line-height: 23px">
<li style="line-height: 23px; margin-bottom: 8px;">
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Clear resolution criteria. </b>This is
needed so users know how you are going to
decide on what the correct answer is.</span
>
style="font-family: Readex Pro, Arial, Helvetica, sans-serif;font-size: 17px;"><b>Interesting
topic. </b>Manifold gives
creators M$10 for
each unique trader that bets on your
market, so it pays to ask a question people are interested in!</span>
</li>
<li style="line-height: 23px">
<span
style="
<li style="line-height: 23px; margin-bottom: 8px;">
<span style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Clear resolution date</b>. This is
sometimes slightly different from the closing
date. We recommend leaving the market open up
until you resolve it, but if it is different
make sure you say what day you intend to
resolve it in the description!</span
>
"><b>Clear resolution criteria. </b>Any ambiguities or edge cases in your description
will drive traders away from your markets.</span>
</li>
<li style="line-height: 23px">
<span
style="
<li style="line-height: 23px; margin-bottom: 8px;">
<span style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Detailed description. </b>Use the rich
text editor to create an easy to read
description. Include any context or background
"><b>Detailed description. </b>Include images/videos/tweets and any context or
background
information that could be useful to people who
are interested in learning more that are
uneducated on the subject.</span
>
uneducated on the subject.</span>
</li>
<li style="line-height: 23px">
<span
style="
<li style="line-height: 23px; margin-bottom: 8px;">
<span style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Add it to a group. </b>Groups are the
"><b>Add it to a group. </b>Groups are the
primary way users filter for relevant markets.
Also, consider making your own groups and
inviting friends/interested communities to
them from other sites!</span
>
them from other sites!</span>
</li>
<li style="line-height: 23px">
<span
style="
<li style="line-height: 23px; margin-bottom: 8px;">
<span style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Bonus: </b>Add a comment on your
prediction and explain (with links and
sources) supporting it.</span
>
"><b>Share it on social media</b>. You'll earn the <a class="link-build-content"
style="color: inherit; text-decoration: none" target="_blank"
href="https://manifold.markets/referrals"><span style="
color: #55575d;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"><u>M$500
referral bonus</u></span></a> if you get new users to sign up!</span>
</li>
</ul>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq">
&nbsp;
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq">
<span style="
color: #292fd7;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 20px;
"
><b
>Examples of markets you should
emulate!&nbsp;</b
></span
>
"><b>Examples of markets you should
emulate!&nbsp;</b></span>
</p>
<ul>
<li style="line-height: 23px">
<a
class="link-build-content"
style="color: inherit; text-decoration: none"
<a class="link-build-content" style="color: inherit; text-decoration: none"
target="_blank"
href="https://manifold.markets/DavidChee/will-our-upcoming-twitch-bot-be-a-s"
><span
href="https://manifold.markets/DavidChee/will-our-upcoming-twitch-bot-be-a-s"><span
style="
color: #55575d;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><u>This complex market</u></span
></a
><span
style="
"><u>This complex market</u></span></a><span style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>
about the project I am working on.</span
>
">
about the project I am working on.</span>
</li>
<li style="line-height: 23px">
<a
class="link-build-content"
style="color: inherit; text-decoration: none"
<a class="link-build-content" style="color: inherit; text-decoration: none"
target="_blank"
href="https://manifold.markets/SneakySly/will-manifold-reach-1000-weekly-act"
><span
href="https://manifold.markets/SneakySly/will-manifold-reach-1000-weekly-act"><span
style="
color: #55575d;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><u>This simple market</u></span
></a
><span
style="
"><u>This simple market</u></span></a><span style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>
">
about Manifold&apos;s weekly active
users.</span
>
users.</span>
</li>
</ul>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq">
&nbsp;
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq">
<span style="
color: #000000;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>Why not </span>
">Why not </span>
<a
class="link-build-content"
style="color: inherit; text-decoration: none"
target="_blank"
href="https://manifold.markets/create"
><span
style="
<a class="link-build-content" style="color: inherit; text-decoration: none" target="_blank"
href="https://manifold.markets/create"><span style="
color: #55575d;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><u>create a market</u></span
></a
><span
style="
"><u>create another market</u></span></a><span style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>
">
while it is still fresh on your mind?
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
<p class="text-build-content" style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq">
<span style="
color: #000000;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>Thanks for reading!</span
>
">Thanks for reading!</span>
</p>
<p
class="text-build-content"
style="
<p class="text-build-content" style="
line-height: 23px;
margin: 10px 0;
margin-bottom: 10px;
"
data-testid="3Q8BP69fq"
>
<span
style="
" data-testid="3Q8BP69fq">
<span style="
color: #000000;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>David from Manifold</span
>
">David from Manifold</span>
</p>
</div>
</td>
@ -606,118 +447,73 @@
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
<tbody>
<tr>
<td
style="
<td style="
direction: ltr;
font-size: 0px;
padding: 0 0 20px 0;
text-align: center;
"
>
">
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="width: 100%">
<tbody>
<tr>
<td
style="
<td style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 20px 0px;
text-align: center;
"
>
">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
<div class="mj-column-per-100 mj-outlook-group-fix" style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align: top; padding: 0">
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td
align="center"
style="
<td align="center" style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
>
<div
style="
">
<div style="
font-family: Ubuntu, Helvetica, Arial,
sans-serif;
font-size: 11px;
line-height: 22px;
text-align: center;
color: #000000;
"
>
">
<p style="margin: 10px 0">
This e-mail has been sent to {{name}},
<a
href="{{unsubscribeLink}}"
style="
<a href="{{unsubscribeLink}}" style="
color: inherit;
text-decoration: none;
"
target="_blank"
>click here to unsubscribe</a
>.
" target="_blank">click here to unsubscribe</a>.
</p>
</div>
</td>
</tr>
<tr>
<td
align="center"
style="
<td align="center" style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
></td>
"></td>
</tr>
</tbody>
</table>
@ -735,4 +531,5 @@
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@ -1,32 +1,33 @@
<!DOCTYPE html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>Welcome to Manifold Markets</title>
<title>Welcome to Manifold Markets!</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
@ -35,6 +36,7 @@
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
@ -52,9 +54,7 @@
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix {
width: 100% !important;
}
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@ -63,10 +63,6 @@
width: 100% !important;
max-width: 100%;
}
.mj-column-per-50 {
width: 50% !important;
max-width: 50%;
}
}
</style>
<style media="screen and (min-width:480px)">
@ -74,615 +70,189 @@
width: 100% !important;
max-width: 100%;
}
.moz-text-html .mj-column-per-50 {
width: 50% !important;
max-width: 50%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
[owa] .mj-column-per-50 {
width: 50% !important;
max-width: 50%;
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing: normal; background-color: #f4f4f4">
<div style="background-color: #f4f4f4">
<body style="word-spacing:normal;background-color:#F4F4F4;">
<div style="background-color:#F4F4F4;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div
style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 600px;
"
>
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%"
>
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 0px 0px 0px 0px;
padding-bottom: 0px;
padding-left: 0px;
padding-right: 0px;
padding-top: 0px;
text-align: center;
"
>
style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tbody>
<tr>
<td align="center"
style="font-size:0px;padding:0px 25px 0px 25px;padding-top:0px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:550px;"><a href="https://manifold.markets/home" target="_blank"><img
alt="" height="auto" src="https://03jlj.mjt.lu/img/03jlj/b/urx/sjtz.gif"
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
width="550"></a></td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 0px 25px 0px 25px;
padding-top: 0px;
padding-right: 25px;
padding-bottom: 0px;
padding-left: 25px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
border-collapse: collapse;
border-spacing: 0px;
"
>
<tbody>
<tr>
<td style="width: 550px">
<a
href="https://manifold.markets/home"
target="_blank"
><img
alt=""
height="auto"
src="https://03jlj.mjt.lu/img/03jlj/b/urx/sjtz.gif"
style="
border: none;
display: block;
outline: none;
text-decoration: none;
height: auto;
width: 100%;
font-size: 13px;
"
width="550"
/></a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
Hi {{name}},</span></p>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<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;">
Welcome! Manifold Markets is a play-money prediction market platform where you can bet on
anything, from elections to Elon Musk to scientific papers to the NBA. </span></p>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div
style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 600px;
"
>
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%"
>
<tbody>
</td>
</tr>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 0px 0px 0px 0px;
padding-bottom: 0px;
padding-left: 0px;
padding-right: 0px;
padding-top: 0px;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
class="mj-column-per-50 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<tr>
<td
align="left"
style="
background: #ffffff;
font-size: 0px;
padding: 0px 15px 0px 50px;
padding-top: 0px;
padding-right: 15px;
padding-bottom: 0px;
padding-left: 50px;
word-break: break-word;
"
>
<div
style="
font-family: Arial, sans-serif;
font-size: 18px;
letter-spacing: normal;
line-height: 1;
text-align: left;
color: #000000;
"
>
<p
class="text-build-content"
data-testid="e885nY30eE"
style="margin: 10px 0; margin-top: 10px"
>
<span
style="
font-family: Arial, sans-serif;
font-size: 18px;
"
><b
>Hi {{name}}, thanks for joining Manifold
Markets!</b
></span
><br /><br /><span
style="
font-family: Arial, sans-serif;
font-size: 18px;
"
>We can&#39;t wait to see what questions you
will ask!</span
>
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="e885nY30eE"
>
<span
style="
font-family: Arial, sans-serif;
font-size: 18px;
"
>As a gift M$1000 has been credited to your
account - the equivalent of 10 USD.
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;">
</span>
</p>
<p
class="text-build-content"
style="
line-height: 23px;
margin: 10px 0;
margin-bottom: 10px;
"
data-testid="e885nY30eE"
>
<span
style="
font-family: Arial, sans-serif;
font-size: 18px;
"
>Click the buttons to see what you can do with
it!</span
>
</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
<div
class="mj-column-per-50 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 0px 15px 0px 0px;
padding-top: 0px;
padding-right: 15px;
padding-bottom: 0px;
padding-left: 0px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
border-collapse: collapse;
border-spacing: 0px;
"
>
<tbody>
<td>
<p></p>
</td>
</tr>
<tr>
<td style="width: 285px">
<a
href="https://manifold.markets/home"
target="_blank"
>
<img
alt=""
height="auto"
src="https://03jlj.mjt.lu/img/03jlj/b/urx/1rsm.png"
style="
border: none;
display: block;
outline: none;
text-decoration: none;
height: auto;
width: 100%;
font-size: 13px;
"
width="285"
/>
<td align="center">
<table cellspacing="0" cellpadding="0">
<tr>
<td>
<table cellspacing="0" cellpadding="0">
<tr>
<td style="border-radius: 2px;" bgcolor="#4337c9">
<a href="https://manifold.markets" target="_blank"
style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:bold;display: inline-block;">
Explore markets
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 0px 15px 0px 0px;
padding-top: 0px;
padding-right: 15px;
padding-bottom: 0px;
padding-left: 0px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
border-collapse: collapse;
border-spacing: 0px;
"
>
<tbody>
<tr>
<td style="width: 285px">
<a
href="https://manifold.markets/create"
target="_blank"
>
<img
alt=""
height="auto"
src="https://03jlj.mjt.lu/img/03jlj/b/urx/1rs2.png"
style="
border: none;
display: block;
outline: none;
text-decoration: none;
height: auto;
width: 100%;
font-size: 13px;
"
width="285"
/>
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 0px 15px 0px 0px;
padding-top: 0px;
padding-right: 15px;
padding-bottom: 0px;
padding-left: 0px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
border-collapse: collapse;
border-spacing: 0px;
"
>
<tbody>
<tr>
<td style="width: 285px">
<a
href="https://manifold.markets/charity"
target="_blank"
>
<img
alt=""
height="auto"
src="https://03jlj.mjt.lu/img/03jlj/b/urx/1rsp.png"
style="
border: none;
display: block;
outline: none;
text-decoration: none;
height: auto;
width: 100%;
font-size: 13px;
"
width="285"
/>
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<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 betting and making predictions, you can also <a
class="link-build-content" style="color:inherit;; text-decoration: none;"
target="_blank" href="https://manifold.markets/create"><span
style="color:#55575d;font-family:Arial;font-size:18px;font-weight: bold;"><u>create
your
own
market</u></span></a> on
any question you care about?</span></p>
<p>More resources:</p>
<ul>
<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/about"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Learn more</u></span></a>
about Manifold and how our markets work</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>Refer
your friends</u></span></a> and earn M$500 for each signup!</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://discord.com/invite/eHQBNBqXuh"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Join our Discord
chat</u></span></a></span></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>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div
style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 600px;
"
>
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 0px 0px;
padding-bottom: 0px;
padding-left: 0px;
padding-right: 0px;
padding-top: 20px;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<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
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<tr>
<td
align="left"
style="
font-size: 0px;
padding: 10px 25px;
padding-top: 0px;
padding-bottom: 0px;
word-break: break-word;
"
>
<div
style="
font-family: Arial, sans-serif;
font-size: 18px;
letter-spacing: normal;
line-height: 1;
text-align: left;
color: #000000;
"
>
<p
class="text-build-content"
data-testid="3Q8BP69fq"
style="margin: 10px 0; margin-top: 10px"
>
<span
style="
color: #55575d;
font-family: Arial, Helvetica, sans-serif;
font-size: 18px;
"
>I</span
><span
style="
color: #000000;
font-family: Arial, Helvetica, sans-serif;
font-size: 18px;
"
>f you have any questions or feedback we&#39;d
love to hear from you in our </span
><a
class="link-build-content"
style="color: inherit; text-decoration: none"
target="_blank"
href="https://discord.gg/VARzUpyCSa"
><span
style="
color: #1435b8;
font-family: Arial;
font-size: 18px;
"
><u>Discord server!</u></span
></a
>
</p>
<p
class="text-build-content"
data-testid="3Q8BP69fq"
style="margin: 10px 0"
></p>
<p
class="text-build-content"
data-testid="3Q8BP69fq"
style="margin: 10px 0"
>
<span
style="
color: #000000;
font-family: Arial;
font-size: 18px;
"
>Looking forward to seeing you,</span
>
</p>
<p
class="text-build-content"
data-testid="3Q8BP69fq"
style="margin: 10px 0"
>
<span
style="
color: #000000;
font-family: Arial;
font-size: 18px;
"
>David from Manifold</span
>
</p>
<br />
<p
class="text-build-content"
data-testid="3Q8BP69fq"
style="margin: 10px 0; margin-bottom: 10px"
></p>
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"></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>
@ -696,112 +266,54 @@
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 0 0 20px 0;
text-align: center;
"
></td>
</tr>
</tbody>
</table>
<td style="direction:ltr;font-size:0px;padding:0 0 20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="width:100%;">
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 20px 0px;
text-align: center;
"
>
<td style="direction:ltr;font-size:0px;padding:20px 0px 20px 0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align: top; padding: 0">
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
<td style="vertical-align:top;padding:0;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 10px 25px;
padding-top: 0px;
padding-bottom: 0px;
word-break: break-word;
"
>
<td align="center"
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: 11px;
letter-spacing: normal;
line-height: 22px;
text-align: center;
color: #000000;
"
>
<p style="margin: 10px 0">
This e-mail has been sent to {{name}},
<a
href="{{unsubscribeLink}}"
style="
color: inherit;
text-decoration: none;
"
target="_blank"
>click here to unsubscribe</a
>.
</p>
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
<p style="margin: 10px 0;">This e-mail has been sent to {{name}}, <a
href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;"
target="_blank">click here to unsubscribe</a>.</p>
</div>
</td>
</tr>
<tr>
<td align="center"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
</div>
</td>
</tr>
@ -821,4 +333,5 @@
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

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,7 +12,6 @@ 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',
@ -21,10 +20,9 @@ export const onCreateGroup = functions.firestore
eventId,
group.about,
{
relatedUserId: memberId,
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(() => {
if (serverUser === undefined) {
const cachedUser = localStorage.getItem(CACHED_USER_KEY)
setAuthUser(cachedUser && JSON.parse(cachedUser))
}, [setAuthUser])
}
}, [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,76 +428,35 @@ export function BetsSummary(props: {
: 'NO'
: 'YES'
return (
<Row className={clsx('flex-wrap gap-4 sm:flex-nowrap sm:gap-6', className)}>
{!isCpmm && (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Invested
</div>
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
</Col>
)}
{resolution ? (
<Col>
<div className="text-sm text-gray-500">Payout</div>
<div className="whitespace-nowrap">
{formatMoney(payout)} <ProfitBadge profitPercent={profitPercent} />
</div>
</Col>
) : isBinary ? (
<>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if <YesLabel />
</div>
<div className="whitespace-nowrap">{formatMoney(yesWinnings)}</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if <NoLabel />
</div>
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
</Col>
</>
) : isPseudoNumeric ? (
<>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if {'>='} {formatLargeNumber(contract.max)}
</div>
<div className="whitespace-nowrap">{formatMoney(yesWinnings)}</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if {'<='} {formatLargeNumber(contract.min)}
</div>
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
</Col>
</>
) : (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Current value
</div>
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
</Col>
)}
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
<div className="whitespace-nowrap">
{formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} />
{isYourBets &&
const canSell =
isYourBets &&
isCpmm &&
(isBinary || isPseudoNumeric) &&
!isClosed &&
!resolution &&
hasShares &&
sharesOutcome &&
user && (
user
return (
<Col className={clsx(className, 'gap-4')}>
<Row className="flex-wrap gap-4 sm:flex-nowrap sm:gap-6">
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Invested
</div>
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
<div className="whitespace-nowrap">
{formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} />
</div>
</Col>
{canSell && (
<>
<button
className="btn btn-sm ml-2"
className="btn btn-sm self-end"
onClick={() => setShowSellModal(true)}
>
Sell
@ -492,9 +473,60 @@ export function BetsSummary(props: {
)}
</>
)}
</Row>
<Row className="flex-wrap-none gap-4">
{resolution ? (
<Col>
<div className="text-sm text-gray-500">Payout</div>
<div className="whitespace-nowrap">
{formatMoney(payout)}{' '}
<ProfitBadge profitPercent={profitPercent} />
</div>
</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
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
const contractComments = comments.filter((c) => c.contractId)
const commentsByContract = groupBy(contractComments, 'contractId')
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) => (
<Col className="gap-6">
{items.map((comment) => (
<ProfileComment
key={comment.id}
comment={comment}
className="relative flex items-start space-x-3 pb-6"
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'
export function ContractSearch(props: {
querySortOptions?: {
defaultSort: Sort
defaultFilter?: filter
shouldLoadFromStorage?: boolean
type SearchParameters = {
index: SearchIndex
query: string
numericFilters: SearchOptions['numericFilters']
facetFilters: SearchOptions['facetFilters']
showTime?: ShowTime
}
additionalFilter?: {
type AdditionalFilter = {
creatorId?: string
tag?: string
excludeContractIds?: string[]
groupSlug?: string
}
export function ContractSearch(props: {
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,
onSearchParametersChanged({
index: query ? searchIndex : index,
query: query,
numericFilters: numericFilters,
facetFilters: facetFilters,
showTime:
sort === 'close-date' || sort === 'resolve-date' ? sort : undefined,
})
.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}
/>
)
}
}, [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,20 +66,13 @@ export function ContractCard(props: {
!hideQuickBet
return (
<div>
<Col
<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',
'relative gap-3 self-start rounded-lg bg-white shadow-md hover:cursor-pointer hover:bg-gray-100',
className
)}
>
<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'
)}
>
<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"
@ -106,10 +100,12 @@ export function ContractCard(props: {
/>
</Link>
)}
</div>
<AvatarDetails contract={contract} />
<AvatarDetails
contract={contract}
className={'hidden md:inline-flex'}
/>
<p
className="break-words font-semibold text-indigo-700 peer-hover:underline peer-hover:decoration-indigo-400 peer-hover:decoration-2"
className="break-words font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2"
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
>
{question}
@ -126,35 +122,28 @@ export function ContractCard(props: {
) : (
<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"
className="items-center self-center pr-5"
contract={contract}
/>
)}
{outcomeType === 'PSEUDO_NUMERIC' && (
<PseudoNumericResolutionOrExpectation
className="items-center"
className="items-center self-center pr-5"
contract={contract}
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
className="items-center"
className="items-center self-center pr-5"
contract={contract}
/>
)}
@ -162,17 +151,33 @@ export function ContractCard(props: {
{(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<FreeResponseResolutionOrChance
className="self-end text-gray-600"
className="items-center self-center pr-5 text-gray-600"
contract={contract}
truncate="long"
/>
)}
<ProbBar contract={contract} />
</Col>
</>
)}
<Row
className={clsx(
'absolute bottom-3 gap-2 truncate px-5 md:gap-0',
showQuickBet ? 'w-[85%]' : 'w-full'
)}
>
<AvatarDetails
contract={contract}
short={true}
className={'block md:hidden'}
/>
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showTime={showTime}
hideGroupLink={hideGroupLink}
/>
</Row>
</Row>
</Col>
</div>
)
}
@ -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>
</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>
{disabled ? (
groupInfo
) : (
<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>
{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)
useEffect(() => {
if (isBottomVisible && hasMore) {
const onVisibilityUpdated = useCallback(
(visible) => {
if (visible && loadMore) {
loadMore()
}
}, [isBottomVisible, hasMore, loadMore])
},
[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}
>
<Tooltip className={className} text={toolTip} noTap={noTap}>
{props.children}
</span>
</ClientRender>
<span className="whitespace-nowrap sm:hidden">{props.children}</span>
</>
</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,8 +36,7 @@ export function FeedBet(props: {
const isSelf = user?.id === userId
return (
<>
<Row className={'flex w-full gap-2 pt-3'}>
<Row className={'flex w-full items-center gap-2 pt-3'}>
{isSelf ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
@ -50,21 +52,17 @@ export function FeedBet(props: {
username={bettor.username}
/>
) : (
<div className="relative px-1">
<EmptyAvatar />
</div>
<EmptyAvatar className="mx-1" />
)}
<div className={'min-w-0 flex-1 py-1.5'}>
<BetStatusText
bet={bet}
contract={contract}
isSelf={isSelf}
bettor={bettor}
hideOutcome={hideOutcome}
className="flex-1"
/>
</div>
</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}
elementId={id}
/>
</Row>
<Row className={'text-black'}>
<TruncatedComment
comment={text}
moreHref={groupPath(group.slug)}
shouldTruncate={false}
<div className="mt-2 text-base text-black">
{comments.map((comment) => (
<Content
key={comment.id}
content={comment.content || comment.text}
smallImage
/>
</Row>
))}
</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,18 +62,31 @@ function getMoreNavigation(user?: User | null) {
}
if (!user) {
return [
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 [
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' },
{
@ -80,6 +95,7 @@ function getMoreNavigation(user?: User | null) {
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' },
]),
{
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