diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..27a54149 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,43 @@ +name: Run linter (remove unused imports) + +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: + lint: + name: Auto-lint + 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 lint script + run: yarn lint + - name: Commit any lint changes + if: always() + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Auto-remove unused imports + branch: ${{ github.head_ref }} diff --git a/common/.eslintrc.js b/common/.eslintrc.js index c6f9703e..5212207a 100644 --- a/common/.eslintrc.js +++ b/common/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { - plugins: ['lodash'], + plugins: ['lodash', 'unused-imports'], extends: ['eslint:recommended'], ignorePatterns: ['lib'], env: { @@ -26,6 +26,7 @@ module.exports = { caughtErrorsIgnorePattern: '^_', }, ], + 'unused-imports/no-unused-imports': 'error', }, }, ], diff --git a/common/calculate.ts b/common/calculate.ts index 758fc3cd..da4ce13a 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -140,6 +140,8 @@ function getCpmmInvested(yourBets: Bet[]) { const sortedBets = sortBy(yourBets, 'createdTime') for (const bet of sortedBets) { const { outcome, shares, amount } = bet + if (floatingEqual(shares, 0)) continue + if (amount > 0) { totalShares[outcome] = (totalShares[outcome] ?? 0) + shares totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount diff --git a/common/loans.ts b/common/loans.ts index cb956c09..05b64474 100644 --- a/common/loans.ts +++ b/common/loans.ts @@ -10,7 +10,7 @@ import { import { PortfolioMetrics, User } from './user' import { filterDefined } from './util/array' -const LOAN_DAILY_RATE = 0.01 +const LOAN_DAILY_RATE = 0.02 const calculateNewLoan = (investedValue: number, loanTotal: number) => { const netValue = investedValue - loanTotal diff --git a/common/package.json b/common/package.json index 955e9662..52195398 100644 --- a/common/package.json +++ b/common/package.json @@ -8,11 +8,11 @@ }, "sideEffects": false, "dependencies": { - "@tiptap/core": "2.0.0-beta.181", + "@tiptap/core": "2.0.0-beta.182", "@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", - "@tiptap/starter-kit": "2.0.0-beta.190", + "@tiptap/starter-kit": "2.0.0-beta.191", "lodash": "4.17.21" }, "devDependencies": { diff --git a/common/post.ts b/common/post.ts new file mode 100644 index 00000000..05eab685 --- /dev/null +++ b/common/post.ts @@ -0,0 +1,12 @@ +import { JSONContent } from '@tiptap/core' + +export type Post = { + id: string + title: string + content: JSONContent + creatorId: string // User id + createdTime: number + slug: string +} + +export const MAX_POST_TITLE_LENGTH = 480 diff --git a/common/user.ts b/common/user.ts index 48a3d59c..e3c9d181 100644 --- a/common/user.ts +++ b/common/user.ts @@ -44,6 +44,7 @@ export type User = { currentBettingStreak?: number hasSeenContractFollowModal?: boolean freeMarketsCreated?: number + isBannedFromPosting?: boolean } export type PrivateUser = { diff --git a/common/util/time.ts b/common/util/time.ts index 914949e4..9afb8db4 100644 --- a/common/util/time.ts +++ b/common/util/time.ts @@ -1,2 +1,3 @@ -export const HOUR_MS = 60 * 60 * 1000 +export const MINUTE_MS = 60 * 1000 +export const HOUR_MS = 60 * MINUTE_MS export const DAY_MS = 24 * HOUR_MS diff --git a/firestore.indexes.json b/firestore.indexes.json index 874344be..80b08996 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -40,6 +40,10 @@ "collectionGroup": "comments", "queryScope": "COLLECTION_GROUP", "fields": [ + { + "fieldPath": "commentType", + "order": "ASCENDING" + }, { "fieldPath": "userId", "order": "ASCENDING" diff --git a/firestore.rules b/firestore.rules index 5de1fe64..7ede04c8 100644 --- a/firestore.rules +++ b/firestore.rules @@ -180,5 +180,14 @@ service cloud.firestore { allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember(); } } + + match /posts/{postId} { + allow read; + allow update: if request.auth.uid == resource.data.creatorId + && request.resource.data.diff(resource.data) + .affectedKeys() + .hasOnly(['name', 'content']); + allow delete: if request.auth.uid == resource.data.creatorId; + } } } diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js index 2c607231..55070858 100644 --- a/functions/.eslintrc.js +++ b/functions/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { - plugins: ['lodash'], + plugins: ['lodash', 'unused-imports'], extends: ['eslint:recommended'], ignorePatterns: ['dist', 'lib'], env: { @@ -26,6 +26,7 @@ module.exports = { caughtErrorsIgnorePattern: '^_', }, ], + 'unused-imports/no-unused-imports': 'error', }, }, ], diff --git a/functions/package.json b/functions/package.json index c8f295fc..d5a578de 100644 --- a/functions/package.json +++ b/functions/package.json @@ -26,11 +26,11 @@ "dependencies": { "@amplitude/node": "1.10.0", "@google-cloud/functions-framework": "3.1.2", - "@tiptap/core": "2.0.0-beta.181", + "@tiptap/core": "2.0.0-beta.182", "@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", - "@tiptap/starter-kit": "2.0.0-beta.190", + "@tiptap/starter-kit": "2.0.0-beta.191", "cors": "2.8.5", "dayjs": "1.11.4", "express": "4.18.1", diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts new file mode 100644 index 00000000..40d39bba --- /dev/null +++ b/functions/src/create-post.ts @@ -0,0 +1,83 @@ +import * as admin from 'firebase-admin' + +import { getUser } from './utils' +import { slugify } from '../../common/util/slugify' +import { randomString } from '../../common/util/random' +import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post' +import { APIError, newEndpoint, validate } from './api' +import { JSONContent } from '@tiptap/core' +import { z } from 'zod' + +const contentSchema: z.ZodType = z.lazy(() => + z.intersection( + z.record(z.any()), + z.object({ + type: z.string().optional(), + attrs: z.record(z.any()).optional(), + content: z.array(contentSchema).optional(), + marks: z + .array( + z.intersection( + z.record(z.any()), + z.object({ + type: z.string(), + attrs: z.record(z.any()).optional(), + }) + ) + ) + .optional(), + text: z.string().optional(), + }) + ) +) + +const postSchema = z.object({ + title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), + content: contentSchema, +}) + +export const createpost = newEndpoint({}, async (req, auth) => { + const firestore = admin.firestore() + const { title, content } = validate(postSchema, req.body) + + const creator = await getUser(auth.uid) + if (!creator) + throw new APIError(400, 'No user exists with the authenticated user ID.') + + console.log('creating post owned by', creator.username, 'titled', title) + + const slug = await getSlug(title) + + const postRef = firestore.collection('posts').doc() + + const post: Post = { + id: postRef.id, + creatorId: creator.id, + slug, + title, + createdTime: Date.now(), + content: content, + } + + await postRef.create(post) + + return { status: 'success', post } +}) + +export const getSlug = async (title: string) => { + const proposedSlug = slugify(title) + + const preexistingPost = await getPostFromSlug(proposedSlug) + + return preexistingPost ? proposedSlug + '-' + randomString() : proposedSlug +} + +export async function getPostFromSlug(slug: string) { + const firestore = admin.firestore() + const snap = await firestore + .collection('posts') + .where('slug', '==', slug) + .get() + + return snap.empty ? undefined : (snap.docs[0].data() as Post) +} diff --git a/functions/src/email-templates/market-answer.html b/functions/src/email-templates/market-answer.html index 225436ad..1f7fa5fa 100644 --- a/functions/src/email-templates/market-answer.html +++ b/functions/src/email-templates/market-answer.html @@ -1,84 +1,91 @@ - - - - - Market answer + "> - - - - +
- + - - + + +
+ -
+
- +
- + - - -
+ - +
- + - - - + + Manifold Markets + + + + - - -
+ - Manifold Markets -
+ - +
- + - - - + {{name}} + + + + - - - + {{answer}} + + + + - - -
+ -
- +
+ - {{name}} -
-
+ -
+
- {{answer}} -
-
-
- - +
+ -
-
-
- +
+ + + + + + + + ">unsubscribe. + + + - - - - - - + " valign="top"> + + + + + \ No newline at end of file diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index 711f7ccb..01a53e98 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -1,84 +1,91 @@ - - - - - Market closed + "> - - - - +
- + - - + + +
+ -
+
- +
- + - - -
+ - +
- + - - - + + Manifold Markets + + + + - - - + You asked + + + - - - + {{question}} + + + - - - + Market closed + + + + - - -
+ - Manifold Markets -
+ - You asked -
+ - + - {{question}} -
+ -

+

- Market closed -

-
+ - +
- + - - - + + + - - -
+ - Hi {{name}}, -
+ Hi {{name}}, +
-
+
- A market you created has closed. It's attracted - {{volume}} - in bets — congrats! -
+ A market you created has closed. It's attracted + {{volume}} + in bets — congrats! +
-
+
- Resolve your market to earn {{creatorFee}} as the - creator commission. -
+ Resolve your market to earn {{creatorFee}} as the + creator commission. +
-
+
- Thanks, -
+ Thanks, +
- Manifold Team -
+ Manifold Team +
-
+
-
-
- - +
+ -
-
-
- +
+ + + + + + + + ">unsubscribe. + + + - - - - - - + " valign="top"> + + + + + \ No newline at end of file diff --git a/functions/src/email-templates/market-comment.html b/functions/src/email-templates/market-comment.html index 84118964..0b5b9a54 100644 --- a/functions/src/email-templates/market-comment.html +++ b/functions/src/email-templates/market-comment.html @@ -1,84 +1,91 @@ - - - - - Market comment + "> - - - - +
- + - - + + +
+ -
+
- +
- + - - -
+ - +
- + - - - + + Manifold Markets + + + + - - -
+ - Manifold Markets -
+ - +
- + - - - + {{commentorName}} + {{betDescription}} + + + + - - - + {{comment}} + + + + - - -
+ -
- +
+ - {{commentorName}} - {{betDescription}} -
-
+ -
+
- {{comment}} -
-
-
- - +
+ -
-
-
- +
+ + + + + + + + ">unsubscribe. + + + - - - - - - + " valign="top"> + + + + + \ No newline at end of file diff --git a/functions/src/email-templates/market-resolved.html b/functions/src/email-templates/market-resolved.html index e8b090b5..c1ff3beb 100644 --- a/functions/src/email-templates/market-resolved.html +++ b/functions/src/email-templates/market-resolved.html @@ -1,84 +1,91 @@ - - - - - Market resolved + "> - - - - +
- + - - + + +
+ -
+
- +
- + - - -
+ - +
- + - - - + + Manifold Markets + + + + - - - + {{creatorName}} asked + + + - - - + {{question}} + + + - - - + Resolved {{outcome}} + + + + - - -
+ - Manifold Markets -
+ - {{creatorName}} asked -
+ - + - {{question}} -
+ -

+

- Resolved {{outcome}} -

-
+ - +
- + - - - + + + - - -
+ - Dear {{name}}, -
+ Dear {{name}}, +
-
+
- A market you bet in has been resolved! -
+ A market you bet in has been resolved! +
-
+
- Your investment was - M$ {{investment}}. -
+ Your investment was + {{investment}}. +
-
+
- Your payout is - M$ {{payout}}. -
+ Your payout is + {{payout}}. +
-
+
- Thanks, -
+ Thanks, +
- Manifold Team -
+ Manifold Team +
-
+
-
-
- - +
+ -
-
-
- +
+ + + + + + + + ">unsubscribe. + + + - - - - - - + " valign="top"> + + + + + \ No newline at end of file diff --git a/functions/src/emails.ts b/functions/src/emails.ts index e6e52090..ff313794 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -53,22 +53,29 @@ export const sendMarketResolutionEmail = async ( const subject = `Resolved ${outcome}: ${contract.question}` - // const creatorPayoutText = - // userId === creator.id - // ? ` (plus ${formatMoney(creatorPayout)} in commissions)` - // : '' + const creatorPayoutText = + creatorPayout >= 1 && userId === creator.id + ? ` (plus ${formatMoney(creatorPayout)} in commissions)` + : '' const emailType = 'market-resolved' const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const displayedInvestment = + Number.isNaN(investment) || investment < 0 + ? formatMoney(0) + : formatMoney(investment) + + const displayedPayout = formatMoney(payout) + const templateData: market_resolved_template = { userId: user.id, name: user.name, creatorName: creator.name, question: contract.question, outcome, - investment: `${Math.floor(investment)}`, - payout: `${Math.floor(payout)}`, + investment: displayedInvestment, + payout: displayedPayout + creatorPayoutText, url: `https://${DOMAIN}/${creator.username}/${contract.slug}`, unsubscribeUrl, } diff --git a/functions/src/index.ts b/functions/src/index.ts index b3523eff..a5909748 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -73,6 +73,7 @@ import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' import { getcustomtoken } from './get-custom-token' +import { createpost } from './create-post' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -98,6 +99,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) const getCustomTokenFunction = toCloudFunction(getcustomtoken) +const createPostFunction = toCloudFunction(createpost) export { healthFunction as health, @@ -121,4 +123,5 @@ export { getCurrentUserFunction as getcurrentuser, acceptChallenge as acceptchallenge, getCustomTokenFunction as getcustomtoken, + createPostFunction as createpost, } diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index 0a69521f..08cc16f2 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -5,6 +5,7 @@ import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem' import { Contract } from '../../common/contract' import { User } from '../../common/user' +import { floatingEqual } from '../../common/util/math' export const redeemShares = async (userId: string, contractId: string) => { return await firestore.runTransaction(async (trans) => { @@ -21,7 +22,7 @@ export const redeemShares = async (userId: string, contractId: string) => { const betsSnap = await trans.get(betsColl.where('userId', '==', userId)) const bets = betsSnap.docs.map((doc) => doc.data() as Bet) const { shares, loanPayment, netAmount } = getRedeemableAmount(bets) - if (netAmount === 0) { + if (floatingEqual(netAmount, 0)) { return { status: 'success' } } const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract) diff --git a/functions/src/scripts/backfill-unique-bettors.ts b/functions/src/scripts/backfill-unique-bettors.ts new file mode 100644 index 00000000..35faa54a --- /dev/null +++ b/functions/src/scripts/backfill-unique-bettors.ts @@ -0,0 +1,39 @@ +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' +import { getValues, log, writeAsync } from '../utils' +import { Bet } from '../../../common/bet' +import { groupBy, mapValues, sortBy, uniq } from 'lodash' + +initAdmin() +const firestore = admin.firestore() + +const getBettorsByContractId = async () => { + const bets = await getValues(firestore.collectionGroup('bets')) + log(`Loaded ${bets.length} bets.`) + const betsByContractId = groupBy(bets, 'contractId') + return mapValues(betsByContractId, (bets) => + uniq(sortBy(bets, 'createdTime').map((bet) => bet.userId)) + ) +} + +const updateUniqueBettors = async () => { + const bettorsByContractId = await getBettorsByContractId() + + const updates = Object.entries(bettorsByContractId).map( + ([contractId, userIds]) => { + const update = { + uniqueBettorIds: userIds, + uniqueBettorCount: userIds.length, + } + const docRef = firestore.collection('contracts').doc(contractId) + return { doc: docRef, fields: update } + } + ) + log(`Updating ${updates.length} contracts.`) + await writeAsync(firestore, updates) + log(`Updated all contracts.`) +} + +if (require.main === module) { + updateUniqueBettors() +} diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 8d848f7f..db847a70 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { getcustomtoken } from './get-custom-token' +import { createpost } from './create-post' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -67,6 +68,7 @@ addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/getcurrentuser', getcurrentuser) addEndpointRoute('/getcustomtoken', getcustomtoken) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) +addEndpointRoute('/createpost', createpost) app.listen(PORT) console.log(`Serving functions on port ${PORT}.`) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index a2e72053..9ef3fb10 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -55,16 +55,18 @@ export const updateMetricsCore = async () => { const now = Date.now() const betsByContract = groupBy(bets, (bet) => bet.contractId) - const contractUpdates = contracts.map((contract) => { - const contractBets = betsByContract[contract.id] ?? [] - return { - doc: firestore.collection('contracts').doc(contract.id), - fields: { - volume24Hours: computeVolume(contractBets, now - DAY_MS), - volume7Days: computeVolume(contractBets, now - DAY_MS * 7), - }, - } - }) + const contractUpdates = contracts + .filter((contract) => contract.id) + .map((contract) => { + const contractBets = betsByContract[contract.id] ?? [] + return { + doc: firestore.collection('contracts').doc(contract.id), + fields: { + volume24Hours: computeVolume(contractBets, now - DAY_MS), + volume7Days: computeVolume(contractBets, now - DAY_MS * 7), + }, + } + }) await writeAsync(firestore, contractUpdates) log(`Updated metrics for ${contracts.length} contracts.`) diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 2d620728..a0878e4f 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -4,6 +4,7 @@ import { chunk } from 'lodash' import { Contract } from '../../common/contract' import { PrivateUser, User } from '../../common/user' import { Group } from '../../common/group' +import { Post } from 'common/post' export const log = (...args: unknown[]) => { console.log(`[${new Date().toISOString()}]`, ...args) @@ -80,6 +81,10 @@ export const getGroup = (groupId: string) => { return getDoc('groups', groupId) } +export const getPost = (postId: string) => { + return getDoc('posts', postId) +} + export const getUser = (userId: string) => { return getDoc('users', userId) } diff --git a/package.json b/package.json index 05924ef0..e90daf86 100644 --- a/package.json +++ b/package.json @@ -8,20 +8,22 @@ "web" ], "scripts": { - "verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)" + "verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)", + "lint": "eslint common --fix ; eslint web --fix ; eslint functions --fix" }, "dependencies": {}, "devDependencies": { + "@types/node": "16.11.11", "@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", + "eslint-plugin-unused-imports": "^2.0.0", + "nodemon": "2.0.19", "prettier": "2.5.0", - "typescript": "4.6.4", "ts-node": "10.9.1", - "nodemon": "2.0.19" + "typescript": "4.6.4" }, "resolutions": { "@types/react": "17.0.43" diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 0f103080..56f12813 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -1,6 +1,6 @@ module.exports = { parser: '@typescript-eslint/parser', - plugins: ['lodash'], + plugins: ['lodash', 'unused-imports'], extends: [ 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', @@ -22,6 +22,7 @@ module.exports = { '@next/next/no-typos': 'off', 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], + 'unused-imports/no-unused-imports': 'error', }, env: { browser: true, diff --git a/web/components/advanced-panel.tsx b/web/components/advanced-panel.tsx deleted file mode 100644 index 51caba67..00000000 --- a/web/components/advanced-panel.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import clsx from 'clsx' -import { useState, ReactNode } from 'react' - -export function AdvancedPanel(props: { children: ReactNode }) { - const { children } = props - const [collapsed, setCollapsed] = useState(true) - - return ( -
-
setCollapsed((collapsed) => !collapsed)} - className="cursor-pointer" - > -
- Advanced -
-
-
- -
- {children} -
-
- ) -} diff --git a/web/components/analytics/charts.tsx b/web/components/analytics/charts.tsx index 2f987d58..56e71257 100644 --- a/web/components/analytics/charts.tsx +++ b/web/components/analytics/charts.tsx @@ -1,4 +1,5 @@ import { Point, ResponsiveLine } from '@nivo/line' +import clsx from 'clsx' import dayjs from 'dayjs' import { zip } from 'lodash' import { useWindowSize } from 'web/hooks/use-window-size' @@ -26,8 +27,10 @@ export function DailyCountChart(props: { return (
= 800) ? 400 : 250 }} + className={clsx( + 'h-[250px] w-full overflow-hidden', + !small && 'md:h-[400px]' + )} > = 800) ? 400 : 250 }} + className={clsx( + 'h-[250px] w-full overflow-hidden', + !small && 'md:h-[400px]' + )} > ) : ( - + )} ) diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx index 27152db9..dae3a8b5 100644 --- a/web/components/answers/answers-graph.tsx +++ b/web/components/answers/answers-graph.tsx @@ -71,10 +71,11 @@ export const AnswersGraph = memo(function AnswersGraph(props: { const yTickValues = [0, 25, 50, 75, 100] const numXTickValues = isLargeWidth ? 5 : 2 - const hoursAgo = latestTime.subtract(5, 'hours') - const startDate = dayjs(contract.createdTime).isBefore(hoursAgo) - ? new Date(contract.createdTime) - : hoursAgo.toDate() + const startDate = new Date(contract.createdTime) + const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime) + ? latestTime.add(1, 'hours').toDate() + : latestTime.toDate() + const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2 const multiYear = !dayjs(startDate).isSame(latestTime, 'year') const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime) @@ -96,16 +97,24 @@ export const AnswersGraph = memo(function AnswersGraph(props: { xScale={{ type: 'time', min: startDate, - max: latestTime.toDate(), + max: endDate, }} xFormat={(d) => formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) } axisBottom={{ tickValues: numXTickValues, - format: (time) => formatTime(+time, multiYear, lessThanAWeek, false), + format: (time) => + formatTime(+time, multiYear, lessThanAWeek, includeMinute), }} - colors={{ scheme: 'pastel1' }} + colors={[ + '#fca5a5', // red-300 + '#a5b4fc', // indigo-300 + '#86efac', // green-300 + '#fef08a', // yellow-200 + '#fdba74', // orange-300 + '#c084fc', // purple-400 + ]} pointSize={0} curve="stepAfter" enableSlices="x" @@ -156,7 +165,11 @@ function formatTime( ) { const d = dayjs(time) - if (d.add(1, 'minute').isAfter(Date.now())) return 'Now' + if ( + d.add(1, 'minute').isAfter(Date.now()) && + d.subtract(1, 'minute').isBefore(Date.now()) + ) + return 'Now' let format: string if (d.isSame(Date.now(), 'day')) { diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index ce266778..cef60138 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -25,6 +25,7 @@ import { Bet } from 'common/bet' import { MAX_ANSWER_LENGTH } from 'common/answer' import { withTracking } from 'web/lib/service/analytics' import { lowerCase } from 'lodash' +import { Button } from '../button' export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { const { contract } = props @@ -115,6 +116,8 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = (currentReturn * 100).toFixed() + '%' + if (user?.isBannedFromPosting) return <> + return ( @@ -201,12 +204,14 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { ) : ( text && ( - + Add my answer + ) )} diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index f62c10a2..7347d039 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -19,6 +19,15 @@ import { useStateCheckEquality } from 'web/hooks/use-state-check-equality' type AuthUser = undefined | null | UserAndPrivateUser const CACHED_USER_KEY = 'CACHED_USER_KEY_V2' +// Proxy localStorage in case it's not available (eg in incognito iframe) +const localStorage = + typeof window !== 'undefined' + ? window.localStorage + : { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + } const ensureDeviceToken = () => { let deviceToken = localStorage.getItem('device-token') @@ -46,29 +55,35 @@ export function AuthProvider(props: { }, [setAuthUser, serverUser]) useEffect(() => { - return onIdTokenChanged(auth, async (fbUser) => { - if (fbUser) { - setTokenCookies({ - id: await fbUser.getIdToken(), - refresh: fbUser.refreshToken, - }) - let current = await getUserAndPrivateUser(fbUser.uid) - if (!current.user || !current.privateUser) { - const deviceToken = ensureDeviceToken() - current = (await createUser({ deviceToken })) as UserAndPrivateUser + return onIdTokenChanged( + auth, + async (fbUser) => { + if (fbUser) { + setTokenCookies({ + id: await fbUser.getIdToken(), + refresh: fbUser.refreshToken, + }) + let current = await getUserAndPrivateUser(fbUser.uid) + if (!current.user || !current.privateUser) { + const deviceToken = ensureDeviceToken() + current = (await createUser({ deviceToken })) as UserAndPrivateUser + } + 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(current)) + setCachedReferralInfoForUser(current.user) + } else { + // User logged out; reset to null + deleteTokenCookies() + setAuthUser(null) + localStorage.removeItem(CACHED_USER_KEY) } - 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(current)) - setCachedReferralInfoForUser(current.user) - } else { - // User logged out; reset to null - deleteTokenCookies() - setAuthUser(null) - localStorage.removeItem(CACHED_USER_KEY) + }, + (e) => { + console.error(e) } - }) + ) }, [setAuthUser]) const uid = authUser?.user.id diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 2aca1772..7d84bbc0 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -9,7 +9,7 @@ import { useUserContractBets } from 'web/hooks/use-user-bets' import { useSaveBinaryShares } from './use-save-binary-shares' import { Col } from './layout/col' import { Button } from 'web/components/button' -import { firebaseLogin } from 'web/lib/firebase/users' +import { BetSignUpPrompt } from './sign-up-prompt' /** Button that opens BetPanel in a new modal */ export default function BetButton(props: { @@ -32,15 +32,17 @@ export default function BetButton(props: { return ( <> - + {user ? ( + + ) : ( + + )} {user && (
diff --git a/web/components/bet-inline.tsx b/web/components/bet-inline.tsx index 64780f4b..af75ff7c 100644 --- a/web/components/bet-inline.tsx +++ b/web/components/bet-inline.tsx @@ -12,7 +12,7 @@ import { Row } from './layout/row' import { YesNoSelector } from './yes-no-selector' import { useUnfilledBets } from 'web/hooks/use-bets' import { useUser } from 'web/hooks/use-user' -import { SignUpPrompt } from './sign-up-prompt' +import { BetSignUpPrompt } from './sign-up-prompt' import { getCpmmProbability } from 'common/calculate-cpmm' import { Col } from './layout/col' import { XIcon } from '@heroicons/react/solid' @@ -112,7 +112,7 @@ export function BetInline(props: { : 'Submit'} )} - + + + + {user && ( + + )} + )} @@ -218,7 +234,7 @@ export function ContractDetails(props: { {resolvedDate} @@ -262,14 +278,22 @@ function EditableCloseDate(props: { const [isEditingCloseTime, setIsEditingCloseTime] = useState(false) const [closeDate, setCloseDate] = useState( - closeTime && dayJsCloseTime.format('YYYY-MM-DDTHH:mm') + closeTime && dayJsCloseTime.format('YYYY-MM-DD') ) + const [closeHoursMinutes, setCloseHoursMinutes] = useState( + closeTime && dayJsCloseTime.format('HH:mm') + ) + + const newCloseTime = closeDate + ? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf() + : undefined const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year') const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day') const onSave = () => { - const newCloseTime = dayjs(closeDate).valueOf() + if (!newCloseTime) return + if (newCloseTime === closeTime) setIsEditingCloseTime(false) else if (newCloseTime > Date.now()) { const content = contract.description @@ -294,20 +318,28 @@ function EditableCloseDate(props: { return ( <> {isEditingCloseTime ? ( -
+ e.stopPropagation()} - onChange={(e) => setCloseDate(e.target.value || '')} + onChange={(e) => setCloseDate(e.target.value)} min={Date.now()} value={closeDate} /> -
+ e.stopPropagation()} + onChange={(e) => setCloseHoursMinutes(e.target.value)} + min="00:00" + value={closeHoursMinutes} + /> + ) : ( Date.now() ? 'Trading ends:' : 'Trading ended:'} - time={dayJsCloseTime} + time={closeTime} > {isSameYear ? dayJsCloseTime.format('MMM D') @@ -327,7 +359,7 @@ function EditableCloseDate(props: { color={'gray-white'} onClick={() => setIsEditingCloseTime(true)} > - Edit + ))} diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 7c35a071..f418db06 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -17,6 +17,7 @@ import { useAdmin, useDev } from 'web/hooks/use-admin' import { SiteLink } from '../site-link' import { firestoreConsolePath } from 'common/envs/constants' import { deleteField } from 'firebase/firestore' +import ShortToggle from '../widgets/short-toggle' 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' @@ -31,7 +32,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { const isDev = useDev() const isAdmin = useAdmin() - const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a z') + const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a') const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } = contract @@ -50,6 +51,21 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { ? 'Multiple choice' : 'Numeric' + const onFeaturedToggle = async (enabled: boolean) => { + if ( + enabled && + (contract.featuredOnHomeRank === 0 || !contract?.featuredOnHomeRank) + ) { + await updateContract(id, { featuredOnHomeRank: 1 }) + setFeatured(true) + } else if (!enabled && (contract?.featuredOnHomeRank ?? 0) > 0) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await updateContract(id, { featuredOnHomeRank: deleteField() }) + setFeatured(false) + } + } + return ( <> - - ) : ( - - )} + + +
) } diff --git a/web/components/datetime-tooltip.tsx b/web/components/datetime-tooltip.tsx index d820e728..c81d5d88 100644 --- a/web/components/datetime-tooltip.tsx +++ b/web/components/datetime-tooltip.tsx @@ -1,15 +1,12 @@ -import dayjs, { Dayjs } from 'dayjs' -import utc from 'dayjs/plugin/utc' -import timezone from 'dayjs/plugin/timezone' -import advanced from 'dayjs/plugin/advancedFormat' import { Tooltip } from './tooltip' -dayjs.extend(utc) -dayjs.extend(timezone) -dayjs.extend(advanced) +const FORMATTER = new Intl.DateTimeFormat('default', { + dateStyle: 'medium', + timeStyle: 'long', +}) export function DateTimeTooltip(props: { - time: Dayjs + time: number text?: string className?: string children?: React.ReactNode @@ -17,7 +14,7 @@ export function DateTimeTooltip(props: { }) { const { className, time, text, noTap } = props - const formattedTime = time.format('MMM DD, YYYY hh:mm a z') + const formattedTime = FORMATTER.format(time) const toolTip = text ? `${text} ${formattedTime}` : formattedTime return ( diff --git a/web/components/editor/embed-modal.tsx b/web/components/editor/embed-modal.tsx index 6acfd8f0..48ccbfbb 100644 --- a/web/components/editor/embed-modal.tsx +++ b/web/components/editor/embed-modal.tsx @@ -40,6 +40,11 @@ const embedPatterns: EmbedPattern[] = [ rewrite: (id) => ``, }, + { + regex: /^(https?:\/\/www\.figma\.com\/(?:file|proto)\/[^\/]+\/[^\/]+)/, + rewrite: (url) => + ``, + }, // Twitch is a bit annoying, since it requires the `&parent=DOMAIN` to match { // Twitch: https://www.twitch.tv/videos/1445087149 diff --git a/web/components/editor/market-modal.tsx b/web/components/editor/market-modal.tsx index ea62b960..a81953de 100644 --- a/web/components/editor/market-modal.tsx +++ b/web/components/editor/market-modal.tsx @@ -48,8 +48,17 @@ export function MarketModal(props: { {contracts.length > 1 && 's'} )} - )} diff --git a/web/components/feed/copy-link-date-time.tsx b/web/components/feed/copy-link-date-time.tsx index 8238d3e3..c4e69655 100644 --- a/web/components/feed/copy-link-date-time.tsx +++ b/web/components/feed/copy-link-date-time.tsx @@ -6,8 +6,6 @@ import Link from 'next/link' 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 @@ -18,7 +16,6 @@ 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 @@ -31,26 +28,19 @@ export function CopyLinkDateTimeComponent(props: { setTimeout(() => setShowToast(false), 2000) } return ( - + + + + {fromNow(createdTime)} + {showToast && } + + + + ) } diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index d6950b37..5c9eb0ab 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -6,7 +6,6 @@ import { useUser, useUserById } from 'web/hooks/use-user' import { Row } from 'web/components/layout/row' import { Avatar, EmptyAvatar } from 'web/components/avatar' import clsx from 'clsx' -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' @@ -154,79 +153,3 @@ export function BetStatusText(props: {
) } - -function BetGroupSpan(props: { - contract: Contract - bets: Bet[] - outcome?: string -}) { - const { contract, bets, outcome } = props - - const numberTraders = uniqBy(bets, (b) => b.userId).length - - const [buys, sells] = partition(bets, (bet) => bet.amount >= 0) - const buyTotal = sumBy(buys, (b) => b.amount) - const sellTotal = sumBy(sells, (b) => -b.amount) - - return ( - - {numberTraders} {numberTraders > 1 ? 'traders' : 'trader'}{' '} - - {buyTotal > 0 && <>bought {formatMoney(buyTotal)} } - {sellTotal > 0 && <>sold {formatMoney(sellTotal)} } - - {outcome && ( - <> - {' '} - of{' '} - - - )}{' '} - - ) -} - -export function FeedBetGroup(props: { - contract: Contract - bets: Bet[] - hideOutcome: boolean -}) { - const { contract, bets, hideOutcome } = props - - const betGroups = groupBy(bets, (bet) => bet.outcome) - const outcomes = Object.keys(betGroups) - - // Use the time of the last bet for the entire group - const createdTime = bets[bets.length - 1].createdTime - - return ( - <> -
-
-
-
-
-
-
-
- {outcomes.map((outcome, index) => ( - - - {index !== outcomes.length - 1 &&
} -
- ))} - -
-
- - ) -} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 78e971c1..8648c6f5 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -382,6 +382,8 @@ export function CommentInput(props: { const isNumeric = contract.outcomeType === 'NUMERIC' + if (user?.isBannedFromPosting) return <> + return ( <> @@ -535,7 +537,7 @@ export function CommentInputTextArea(props: { className={'btn btn-outline btn-sm mt-2 normal-case'} onClick={() => submitComment(presetId)} > - Sign in to comment + Add my comment )} diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index 3491771d..dc7a5e1b 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -33,7 +33,7 @@ import { import { FeedBet } from 'web/components/feed/feed-bets' import { CPMMBinaryContract, NumericContract } from 'common/contract' import { FeedLiquidity } from './feed-liquidity' -import { SignUpPrompt } from '../sign-up-prompt' +import { BetSignUpPrompt } from '../sign-up-prompt' import { User } from 'common/user' import { PlayMoneyDisclaimer } from '../play-money-disclaimer' import { contractMetrics } from 'common/contract-details' @@ -70,7 +70,7 @@ export function FeedItems(props: { {!user ? ( - + ) : ( diff --git a/web/components/file-upload-button.tsx b/web/components/file-upload-button.tsx index 3ff15d91..0872fc1b 100644 --- a/web/components/file-upload-button.tsx +++ b/web/components/file-upload-button.tsx @@ -10,7 +10,11 @@ export function FileUploadButton(props: { const ref = useRef(null) return ( <> - + return (