diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 00000000..2aa95e44 --- /dev/null +++ b/.github/workflows/format.yml @@ -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 }} diff --git a/common/bet.ts b/common/bet.ts index d5072c0f..56e050a7 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -26,6 +26,7 @@ export type Bet = { isAnte?: boolean isLiquidityProvision?: boolean isRedemption?: boolean + challengeSlug?: string } & Partial export type NumericBet = Bet & { diff --git a/common/challenge.ts b/common/challenge.ts new file mode 100644 index 00000000..9bac8c08 --- /dev/null +++ b/common/challenge.ts @@ -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 diff --git a/common/comment.ts b/common/comment.ts index 0d0c4daf..a217b292 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,3 +1,5 @@ +import type { JSONContent } from '@tiptap/core' + // Currently, comments are created after the bet, not atomically with the bet. // They're uniquely identified by the pair contractId/betId. export type Comment = { @@ -9,7 +11,9 @@ export type Comment = { replyToCommentId?: string userId: string - text: string + /** @deprecated - content now stored as JSON in content*/ + text?: string + content: JSONContent createdTime: number // Denormalized, for rendering comments diff --git a/common/notification.ts b/common/notification.ts index 5fd4236b..fa4cd90a 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -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' diff --git a/common/user.ts b/common/user.ts index 0dac5a19..2aeb7122 100644 --- a/common/user.ts +++ b/common/user.ts @@ -40,12 +40,14 @@ export type User = { referredByContractId?: string referredByGroupId?: string lastPingTime?: number + shouldShowWelcome?: boolean } export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 // for sus users, i.e. multiple sign ups for same person export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10 export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500 + export type PrivateUser = { id: string // same as User.id username: string // denormalized from User @@ -55,6 +57,7 @@ export type PrivateUser = { unsubscribedFromCommentEmails?: boolean unsubscribedFromAnswerEmails?: boolean unsubscribedFromGenericEmails?: boolean + manaBonusEmailSent?: boolean initialDeviceToken?: string initialIpAddress?: string apiKey?: string diff --git a/common/util/parse.ts b/common/util/parse.ts index cacd0862..f07e4097 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -22,6 +22,7 @@ import { Image } from '@tiptap/extension-image' import { Link } from '@tiptap/extension-link' import { Mention } from '@tiptap/extension-mention' import Iframe from './tiptap-iframe' +import { uniq } from 'lodash' export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi @@ -61,6 +62,15 @@ const checkAgainstQuery = (query: string, corpus: string) => export const searchInAny = (query: string, ...fields: string[]) => fields.some((field) => checkAgainstQuery(query, field)) +/** @return user ids of all \@mentions */ +export function parseMentions(data: JSONContent): string[] { + const mentions = data.content?.flatMap(parseMentions) ?? [] //dfs + if (data.type === 'mention' && data.attrs) { + mentions.push(data.attrs.id as string) + } + return uniq(mentions) +} + // can't just do [StarterKit, Image...] because it doesn't work with cjs imports export const exhibitExts = [ Blockquote, diff --git a/docs/docs/api.md b/docs/docs/api.md index 8b7dce30..48564cb3 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -46,6 +46,28 @@ Gets a user by their unique ID. Many other API endpoints return this as the `use Requires no authorization. +### GET /v0/me + +Returns the authenticated user. + +### `GET /v0/groups` + +Gets all groups, in no particular order. + +Requires no authorization. + +### `GET /v0/groups/[slug]` + +Gets a group by its slug. + +Requires no authorization. + +### `GET /v0/groups/by-id/[id]` + +Gets a group by its unique ID. + +Requires no authorization. + ### `GET /v0/markets` Lists all markets, ordered by creation date descending. @@ -481,6 +503,20 @@ Parameters: answer. For numeric markets, this is a string representing the target bucket, and an additional `value` parameter is required which is a number representing the target value. (Bet on numeric markets at your own peril.) +- `limitProb`: Optional. A number between `0.001` and `0.999` inclusive representing + the limit probability for your bet (i.e. 0.1% to 99.9% — multiply by 100 for the + probability percentage). + The bet will execute immediately in the direction of `outcome`, but not beyond this + specified limit. If not all the bet is filled, the bet will remain as an open offer + that can later be matched against an opposite direction bet. + - For example, if the current market probability is `50%`: + - A `M$10` bet on `YES` with `limitProb=0.4` would not be filled until the market + probability moves down to `40%` and someone bets `M$15` of `NO` to match your + bet odds. + - A `M$100` bet on `YES` with `limitProb=0.6` would fill partially or completely + depending on current unfilled limit bets and the AMM's liquidity. Any remaining + portion of the bet not filled would remain to be matched against in the future. + - An unfilled limit order bet can be cancelled using the cancel API. Example request: @@ -581,12 +617,12 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ ### `POST /v0/market/[marketId]/sell` -Sells some quantity of shares in a market on behalf of the authorized user. +Sells some quantity of shares in a binary market on behalf of the authorized user. Parameters: -- `outcome`: Required. One of `YES`, `NO`, or a `number` indicating the numeric - bucket ID, depending on the market type. +- `outcome`: Optional. One of `YES`, or `NO`. If you leave it off, and you only + own one kind of shares, you will sell that kind of shares. - `shares`: Optional. The amount of shares to sell of the outcome given above. If not provided, all the shares you own will be sold. @@ -617,7 +653,7 @@ Requires no authorization. - Example request ``` - https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-california-abolish-daylight-sa + https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-i-be-able-to-place-a-limit-ord ``` - Response type: A `Bet[]`. @@ -625,31 +661,60 @@ Requires no authorization. ```json [ + // Limit bet, partially filled. { - "probAfter": 0.44418877319153904, - "shares": -645.8346334931828, + "isFilled": false, + "amount": 15.596681605353808, + "userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2", + "contractId": "Tz5dA01GkK5QKiQfZeDL", + "probBefore": 0.5730753474948571, + "isCancelled": false, "outcome": "YES", - "contractId": "tgB1XmvFXZNhjr3xMNLp", - "sale": { - "betId": "RcOtarI3d1DUUTjiE0rx", - "amount": 474.9999999999998 - }, - "createdTime": 1644602886293, - "userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2", - "probBefore": 0.7229189477449224, - "id": "x9eNmCaqQeXW8AgJ8Zmp", - "amount": -499.9999999999998 + "fees": { "creatorFee": 0, "liquidityFee": 0, "platformFee": 0 }, + "shares": 31.193363210707616, + "limitProb": 0.5, + "id": "yXB8lVbs86TKkhWA1FVi", + "loanAmount": 0, + "orderAmount": 100, + "probAfter": 0.5730753474948571, + "createdTime": 1659482775970, + "fills": [ + { + "timestamp": 1659483249648, + "matchedBetId": "MfrMd5HTiGASDXzqibr7", + "amount": 15.596681605353808, + "shares": 31.193363210707616 + } + ] }, + // Normal bet (no limitProb specified). { - "probAfter": 0.9901970375647697, - "contractId": "zdeaYVAfHlo9jKzWh57J", - "outcome": "YES", - "amount": 1, - "id": "8PqxKYwXCcLYoXy2m2Nm", - "shares": 1.0049875638533763, - "userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2", - "probBefore": 0.9900000000000001, - "createdTime": 1644705818872 + "shares": 17.350459904608414, + "probBefore": 0.5304358279113885, + "isFilled": true, + "probAfter": 0.5730753474948571, + "userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2", + "amount": 10, + "contractId": "Tz5dA01GkK5QKiQfZeDL", + "id": "1LPJHNz5oAX4K6YtJlP1", + "fees": { + "platformFee": 0, + "liquidityFee": 0, + "creatorFee": 0.4251333951457593 + }, + "isCancelled": false, + "loanAmount": 0, + "orderAmount": 10, + "fills": [ + { + "amount": 10, + "matchedBetId": null, + "shares": 17.350459904608414, + "timestamp": 1659482757271 + } + ], + "createdTime": 1659482757271, + "outcome": "YES" } ] ``` diff --git a/docs/docs/awesome-manifold.md b/docs/docs/awesome-manifold.md index 44167bcb..0871be52 100644 --- a/docs/docs/awesome-manifold.md +++ b/docs/docs/awesome-manifold.md @@ -10,6 +10,7 @@ A list of community-created projects built on, or related to, Manifold Markets. - [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government - [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold +- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$. ## API / Dev @@ -21,3 +22,4 @@ A list of community-created projects built on, or related to, Manifold Markets. ## Bots - [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon +- [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets diff --git a/firestore.rules b/firestore.rules index 0f28ca80..b0befc85 100644 --- a/firestore.rules +++ b/firestore.rules @@ -22,7 +22,7 @@ service cloud.firestore { allow read; allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime']); + .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']); // User referral rules allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() @@ -39,6 +39,17 @@ service cloud.firestore { allow read; } + match /{somePath=**}/challenges/{challengeId}{ + allow read; + } + + match /contracts/{contractId}/challenges/{challengeId}{ + allow read; + allow create: if request.auth.uid == request.resource.data.creatorId; + // allow update if there have been no claims yet and if the challenge is still open + allow update: if request.auth.uid == resource.data.creatorId; + } + match /users/{userId}/follows/{followUserId} { allow read; allow write: if request.auth.uid == userId; diff --git a/functions/package.json b/functions/package.json index b20a8fd0..b0d8e458 100644 --- a/functions/package.json +++ b/functions/package.json @@ -31,6 +31,7 @@ "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/starter-kit": "2.0.0-beta.190", + "dayjs": "1.11.4", "cors": "2.8.5", "express": "4.18.1", "firebase-admin": "10.0.0", diff --git a/functions/src/accept-challenge.ts b/functions/src/accept-challenge.ts new file mode 100644 index 00000000..eae6ab55 --- /dev/null +++ b/functions/src/accept-challenge.ts @@ -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 } +}) diff --git a/functions/src/api.ts b/functions/src/api.ts index fdda0ad5..e9a488c2 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -78,6 +78,19 @@ export const lookupUser = async (creds: Credentials): Promise => { } } +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 diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 786ee8ae..44ced6a8 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -14,7 +14,7 @@ import { import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' -import { chargeUser } from './utils' +import { chargeUser, getContract } from './utils' import { APIError, newEndpoint, validate, zTimestamp } from './api' import { @@ -28,11 +28,11 @@ import { Answer, getNoneAnswer } from '../../common/answer' import { getNewContract } from '../../common/new-contract' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { User } from '../../common/user' -import { Group, MAX_ID_LENGTH } from '../../common/group' +import { Group, GroupLink, MAX_ID_LENGTH } from '../../common/group' import { getPseudoProbability } from '../../common/pseudo-numeric' import { JSONContent } from '@tiptap/core' -import { zip } from 'lodash' -import { Bet } from 'common/bet' +import { uniq, zip } from 'lodash' +import { Bet } from '../../common/bet' const descScehma: z.ZodType = z.lazy(() => z.intersection( @@ -136,27 +136,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => { const slug = await getSlug(question) const contractRef = firestore.collection('contracts').doc() - let group = null - if (groupId) { - const groupDocRef = firestore.collection('groups').doc(groupId) - const groupDoc = await groupDocRef.get() - if (!groupDoc.exists) { - throw new APIError(400, 'No group exists with the given group ID.') - } - - group = groupDoc.data() as Group - if (!group.memberIds.includes(user.id)) { - throw new APIError( - 400, - 'User must be a member of the group to add markets to it.' - ) - } - if (!group.contractIds.includes(contractRef.id)) - await groupDocRef.update({ - contractIds: [...group.contractIds, contractRef.id], - }) - } - console.log( 'creating contract for', user.username, @@ -188,6 +167,33 @@ export const createmarket = newEndpoint({}, async (req, auth) => { await contractRef.create(contract) + let group = null + if (groupId) { + const groupDocRef = firestore.collection('groups').doc(groupId) + const groupDoc = await groupDocRef.get() + if (!groupDoc.exists) { + throw new APIError(400, 'No group exists with the given group ID.') + } + + group = groupDoc.data() as Group + if ( + !group.memberIds.includes(user.id) && + !group.anyoneCanJoin && + group.creatorId !== user.id + ) { + throw new APIError( + 400, + 'User must be a member/creator of the group or group must be open to add markets to it.' + ) + } + if (!group.contractIds.includes(contractRef.id)) { + await createGroupLinks(group, [contractRef.id], auth.uid) + await groupDocRef.update({ + contractIds: uniq([...group.contractIds, contractRef.id]), + }) + } + } + const providerId = user.id if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { @@ -284,3 +290,38 @@ export async function getContractFromSlug(slug: string) { return snap.empty ? undefined : (snap.docs[0].data() as Contract) } + +async function createGroupLinks( + group: Group, + contractIds: string[], + userId: string +) { + for (const contractId of contractIds) { + const contract = await getContract(contractId) + if (!contract?.groupSlugs?.includes(group.slug)) { + await firestore + .collection('contracts') + .doc(contractId) + .update({ + groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]), + }) + } + if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) { + await firestore + .collection('contracts') + .doc(contractId) + .update({ + groupLinks: [ + { + groupId: group.id, + name: group.name, + slug: group.slug, + userId, + createdTime: Date.now(), + } as GroupLink, + ...(contract?.groupLinks ?? []), + ], + }) + } + } +} diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 7cc05760..51b884ad 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -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)) +} diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index ab7c8e9a..c30e78c3 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,5 +1,7 @@ import * as admin from 'firebase-admin' import { z } from 'zod' +import { uniq } from 'lodash' + import { MANIFOLD_AVATAR_URL, MANIFOLD_USERNAME, @@ -24,7 +26,6 @@ import { import { track } from './analytics' import { APIError, newEndpoint, validate } from './api' import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' -import { uniq } from 'lodash' import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, @@ -77,6 +78,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, followerCountCached: 0, followedCategories: DEFAULT_CATEGORIES, + shouldShowWelcome: true, } await firestore.collection('users').doc(auth.uid).create(user) @@ -92,8 +94,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => { await firestore.collection('private-users').doc(auth.uid).create(privateUser) - await sendWelcomeEmail(user, privateUser) await addUserToDefaultGroups(user) + await sendWelcomeEmail(user, privateUser) await track(auth.uid, 'create user', { username }, { ip: req.ip }) return user diff --git a/functions/src/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html index 5f0c450e..1ef9dbb7 100644 --- a/functions/src/email-templates/500-mana.html +++ b/functions/src/email-templates/500-mana.html @@ -1,12 +1,48 @@ - + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + +
+
+
+

Thanks for + using Manifold Markets. Running low + on mana (M$)? Click the link below to receive a one time gift of M$500!

+
+
+

+
+ + + + +
+ + + + +
+ + Claim M$500 + +
+
+
+
+

+ +

 

+

Cheers,

+

David from Manifold

+

 

+
+
+
+ +
+
+ +
+ + + +
+ +
+ + + +
+ + + +
+
+ + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

This e-mail has been sent to {{name}}, click here to unsubscribe.

+
+
+
+
+
+
+
+ +
+ + + + + + \ No newline at end of file diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html new file mode 100644 index 00000000..64273e7c --- /dev/null +++ b/functions/src/email-templates/creating-market.html @@ -0,0 +1,738 @@ + + + + (no subject) + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+

+ 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! +

+

+   +

+

+ Manifold also gives its creators 10 Mana for + each unique trader that bets on your + market! +

+

+   +

+

+ What makes a good question? +

+
    +
  • + Clear resolution criteria. This is + needed so users know how you are going to + decide on what the correct answer is. +
  • +
  • + Clear resolution date. 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! +
  • +
  • + Detailed description. Use the rich + text editor to create an easy to read + description. Include any context or background + information that could be useful to people who + are interested in learning more that are + uneducated on the subject. +
  • +
  • + Add it to a group. 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! +
  • +
  • + Bonus: Add a comment on your + prediction and explain (with links and + sources) supporting it. +
  • +
+

+   +

+

+ Examples of markets you should + emulate!  +

+ +

+   +

+

+ Why not + + + + create a market + while it is still fresh on your mind? +

+

+ Thanks for reading! +

+

+ David from Manifold +

+
+
+
+ +
+
+ +
+ + + + + +
+ + </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> + )} + </> + ) +} diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx new file mode 100644 index 00000000..e93ec314 --- /dev/null +++ b/web/components/challenges/create-challenge-modal.tsx @@ -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> + </> + )} + </> + ) +} diff --git a/web/components/comments-list.tsx b/web/components/comments-list.tsx index f8e1d7e1..2a467f6d 100644 --- a/web/components/comments-list.tsx +++ b/web/components/comments-list.tsx @@ -8,8 +8,8 @@ import { RelativeTimestamp } from './relative-timestamp' import { UserLink } from './user-page' import { User } from 'common/user' import { Col } from './layout/col' -import { Linkify } from './linkify' import { groupBy } from 'lodash' +import { Content } from './editor' export function UserCommentsList(props: { user: User @@ -50,7 +50,8 @@ export function UserCommentsList(props: { function ProfileComment(props: { comment: Comment; className?: string }) { const { comment, className } = props - const { text, userUsername, userName, userAvatarUrl, createdTime } = comment + const { text, content, userUsername, userName, userAvatarUrl, createdTime } = + comment // TODO: find and attach relevant bets by comment betId at some point return ( <Row className={className}> @@ -64,7 +65,7 @@ function ProfileComment(props: { comment: Comment; className?: string }) { />{' '} <RelativeTimestamp time={createdTime} /> </p> - <Linkify text={text} /> + <Content content={content || text} /> </div> </Row> ) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index c7660138..265b25c6 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -1,26 +1,14 @@ /* eslint-disable react-hooks/exhaustive-deps */ import algoliasearch from 'algoliasearch/lite' -import { - Configure, - InstantSearch, - SearchBox, - SortBy, - useInfiniteHits, - useSortBy, -} from 'react-instantsearch-hooks-web' import { Contract } from 'common/contract' -import { - Sort, - useInitialQueryAndSort, - useUpdateQueryAndSort, -} from '../hooks/use-sort-and-query-params' +import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params' import { ContractHighlightOptions, ContractsGrid, -} from './contract/contracts-list' +} from './contract/contracts-grid' import { Row } from './layout/row' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Spacer } from './layout/spacer' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { useUser } from 'web/hooks/use-user' @@ -30,8 +18,9 @@ import ContractSearchFirestore from 'web/pages/contract-search-firestore' import { useMemberGroups } from 'web/hooks/use-group' import { Group, NEW_USER_GROUP_SLUGS } from 'common/group' import { PillButton } from './buttons/pill-button' -import { sortBy } from 'lodash' +import { range, sortBy } from 'lodash' import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' +import { Col } from './layout/col' const searchClient = algoliasearch( 'GJQPAYENIF', @@ -39,17 +28,17 @@ const searchClient = algoliasearch( ) const indexPrefix = ENV === 'DEV' ? 'dev-' : '' +const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex' -const sortIndexes = [ - { label: 'Newest', value: indexPrefix + 'contracts-newest' }, - // { label: 'Oldest', value: indexPrefix + 'contracts-oldest' }, - { label: 'Most popular', value: indexPrefix + 'contracts-score' }, - { label: 'Most traded', value: indexPrefix + 'contracts-most-traded' }, - { label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' }, - { label: 'Last updated', value: indexPrefix + 'contracts-last-updated' }, - { label: 'Subsidy', value: indexPrefix + 'contracts-liquidity' }, - { label: 'Close date', value: indexPrefix + 'contracts-close-date' }, - { label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' }, +const sortOptions = [ + { label: 'Newest', value: 'newest' }, + { label: 'Trending', value: 'score' }, + { label: 'Most traded', value: 'most-traded' }, + { label: '24h volume', value: '24-hour-vol' }, + { label: 'Last updated', value: 'last-updated' }, + { label: 'Subsidy', value: 'liquidity' }, + { label: 'Close date', value: 'close-date' }, + { label: 'Resolve date', value: 'resolve-date' }, ] export const DEFAULT_SORT = 'score' @@ -108,77 +97,154 @@ export function ContractSearch(props: { memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups const follows = useFollows(user?.id) - const { initialSort } = useInitialQueryAndSort(querySortOptions) - const sort = sortIndexes - .map(({ value }) => value) - .includes(`${indexPrefix}contracts-${initialSort ?? ''}`) - ? initialSort - : querySortOptions?.defaultSort ?? DEFAULT_SORT + const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {} + const { query, setQuery, sort, setSort } = useQueryAndSortParams({ + defaultSort, + shouldLoadFromStorage, + }) const [filter, setFilter] = useState<filter>( querySortOptions?.defaultFilter ?? 'open' ) - const pillsEnabled = !additionalFilter + const pillsEnabled = !additionalFilter && !query const [pillFilter, setPillFilter] = useState<string | undefined>(undefined) - const selectFilter = (pill: string | undefined) => () => { + const selectPill = (pill: string | undefined) => () => { setPillFilter(pill) + setPage(0) track('select search category', { category: pill ?? 'all' }) } - const { filters, numericFilters } = useMemo(() => { - let filters = [ - filter === 'open' ? 'isResolved:false' : '', - filter === 'closed' ? 'isResolved:false' : '', - filter === 'resolved' ? 'isResolved:true' : '', - additionalFilter?.creatorId - ? `creatorId:${additionalFilter.creatorId}` - : '', - additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '', - additionalFilter?.groupSlug - ? `groupLinks.slug:${additionalFilter.groupSlug}` - : '', - pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' - ? `groupLinks.slug:${pillFilter}` - : '', - pillFilter === 'personal' - ? // Show contracts in groups that the user is a member of - memberGroupSlugs - .map((slug) => `groupLinks.slug:${slug}`) - // Show contracts created by users the user follows - .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) - // Show contracts bet on by users the user follows - .concat( - follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] - ) - : '', - // Subtract contracts you bet on from For you. - pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', - pillFilter === 'your-bets' && user - ? // Show contracts bet on by the user - `uniqueBettorIds:${user.id}` - : '', - ].filter((f) => f) - // Hack to make Algolia work. - filters = ['', ...filters] + const additionalFilters = [ + additionalFilter?.creatorId + ? `creatorId:${additionalFilter.creatorId}` + : '', + additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '', + additionalFilter?.groupSlug + ? `groupLinks.slug:${additionalFilter.groupSlug}` + : '', + ] + const facetFilters = query + ? additionalFilters + : [ + ...additionalFilters, + filter === 'open' ? 'isResolved:false' : '', + filter === 'closed' ? 'isResolved:false' : '', + filter === 'resolved' ? 'isResolved:true' : '', + pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' + ? `groupLinks.slug:${pillFilter}` + : '', + pillFilter === 'personal' + ? // Show contracts in groups that the user is a member of + memberGroupSlugs + .map((slug) => `groupLinks.slug:${slug}`) + // Show contracts created by users the user follows + .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) + // Show contracts bet on by users the user follows + .concat( + follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] + ) + : '', + // Subtract contracts you bet on from For you. + pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', + pillFilter === 'your-bets' && user + ? // Show contracts bet on by the user + `uniqueBettorIds:${user.id}` + : '', + ].filter((f) => f) - const numericFilters = [ - filter === 'open' ? `closeTime > ${Date.now()}` : '', - filter === 'closed' ? `closeTime <= ${Date.now()}` : '', - ].filter((f) => f) - - return { filters, numericFilters } - }, [ - filter, - Object.values(additionalFilter ?? {}).join(','), - memberGroupSlugs.join(','), - (follows ?? []).join(','), - pillFilter, - ]) + const numericFilters = query + ? [] + : [ + filter === 'open' ? `closeTime > ${Date.now()}` : '', + filter === 'closed' ? `closeTime <= ${Date.now()}` : '', + ].filter((f) => f) const indexName = `${indexPrefix}contracts-${sort}` + const index = useMemo(() => searchClient.initIndex(indexName), [indexName]) + const searchIndex = useMemo( + () => searchClient.initIndex(searchIndexName), + [searchIndexName] + ) + + const [page, setPage] = useState(0) + const [numPages, setNumPages] = useState(1) + const [hitsByPage, setHitsByPage] = useState<{ [page: string]: Contract[] }>( + {} + ) + + useEffect(() => { + let wasMostRecentQuery = true + const algoliaIndex = query ? searchIndex : index + + algoliaIndex + .search(query, { + facetFilters, + numericFilters, + page, + hitsPerPage: 20, + }) + .then((results) => { + if (!wasMostRecentQuery) return + + if (page === 0) { + setHitsByPage({ + [0]: results.hits as any as Contract[], + }) + } else { + setHitsByPage((hitsByPage) => ({ + ...hitsByPage, + [page]: results.hits, + })) + } + setNumPages(results.nbPages) + }) + return () => { + wasMostRecentQuery = false + } + // Note numeric filters are unique based on current time, so can't compare + // them by value. + }, [query, page, index, searchIndex, JSON.stringify(facetFilters), filter]) + + const loadMore = () => { + if (page >= numPages - 1) return + + const haveLoadedCurrentPage = hitsByPage[page] + if (haveLoadedCurrentPage) setPage(page + 1) + } + + const hits = range(0, page + 1) + .map((p) => hitsByPage[p] ?? []) + .flat() + + const contracts = hits.filter( + (c) => !additionalFilter?.excludeContractIds?.includes(c.id) + ) + + const showTime = + sort === 'close-date' || sort === 'resolve-date' ? sort : undefined + + const updateQuery = (newQuery: string) => { + setQuery(newQuery) + setPage(0) + } + + const selectFilter = (newFilter: filter) => { + if (newFilter === filter) return + setFilter(newFilter) + setPage(0) + trackCallback('select search filter', { filter: newFilter }) + } + + const selectSort = (newSort: Sort) => { + if (newSort === sort) return + + setPage(0) + setSort(newSort) + track('select sort', { sort: newSort }) + } if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return ( @@ -190,44 +256,40 @@ export function ContractSearch(props: { } return ( - <InstantSearch searchClient={searchClient} indexName={indexName}> + <Col> <Row className="gap-1 sm:gap-2"> - <SearchBox - className="flex-1" - placeholder={showPlaceHolder ? `Search ${filter} contracts` : ''} - classNames={{ - form: 'before:top-6', - input: '!pl-10 !input !input-bordered shadow-none w-[100px]', - resetIcon: 'mt-2 hidden sm:flex', - }} + <input + type="text" + value={query} + onChange={(e) => updateQuery(e.target.value)} + placeholder={showPlaceHolder ? `Search ${filter} markets` : ''} + className="input input-bordered w-full" /> - {/*// TODO track WHICH filter users are using*/} - <select - className="!select !select-bordered" - value={filter} - onChange={(e) => setFilter(e.target.value as filter)} - onBlur={trackCallback('select search filter', { filter })} - > - <option value="open">Open</option> - <option value="closed">Closed</option> - <option value="resolved">Resolved</option> - <option value="all">All</option> - </select> - {!hideOrderSelector && ( - <SortBy - items={sortIndexes} - classNames={{ - select: '!select !select-bordered', - }} - onBlur={trackCallback('select search sort', { sort })} - /> + {!query && ( + <select + className="select select-bordered" + value={filter} + onChange={(e) => selectFilter(e.target.value as filter)} + > + <option value="open">Open</option> + <option value="closed">Closed</option> + <option value="resolved">Resolved</option> + <option value="all">All</option> + </select> + )} + {!hideOrderSelector && !query && ( + <select + className="select select-bordered" + value={sort} + onChange={(e) => selectSort(e.target.value as Sort)} + > + {sortOptions.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> )} - <Configure - facetFilters={filters} - numericFilters={numericFilters} - // Page resets on filters change. - page={0} - /> </Row> <Spacer h={3} /> @@ -237,14 +299,14 @@ export function ContractSearch(props: { <PillButton key={'all'} selected={pillFilter === undefined} - onSelect={selectFilter(undefined)} + onSelect={selectPill(undefined)} > All </PillButton> <PillButton key={'personal'} selected={pillFilter === 'personal'} - onSelect={selectFilter('personal')} + onSelect={selectPill('personal')} > {user ? 'For you' : 'Featured'} </PillButton> @@ -253,7 +315,7 @@ export function ContractSearch(props: { <PillButton key={'your-bets'} selected={pillFilter === 'your-bets'} - onSelect={selectFilter('your-bets')} + onSelect={selectPill('your-bets')} > Your bets </PillButton> @@ -264,7 +326,7 @@ export function ContractSearch(props: { <PillButton key={slug} selected={pillFilter === slug} - onSelect={selectFilter(slug)} + onSelect={selectPill(slug)} > {name} </PillButton> @@ -280,103 +342,17 @@ export function ContractSearch(props: { memberGroupSlugs.length === 0 ? ( <>You're not following anyone, nor in any of your own groups yet.</> ) : ( - <ContractSearchInner - querySortOptions={querySortOptions} + <ContractsGrid + contracts={hitsByPage[0] === undefined ? undefined : contracts} + loadMore={loadMore} + hasMore={true} + showTime={showTime} onContractClick={onContractClick} overrideGridClassName={overrideGridClassName} - excludeContractIds={additionalFilter?.excludeContractIds} highlightOptions={highlightOptions} cardHideOptions={cardHideOptions} /> )} - </InstantSearch> - ) -} - -export function ContractSearchInner(props: { - querySortOptions?: { - defaultSort: Sort - shouldLoadFromStorage?: boolean - } - onContractClick?: (contract: Contract) => void - overrideGridClassName?: string - hideQuickBet?: boolean - excludeContractIds?: string[] - highlightOptions?: ContractHighlightOptions - cardHideOptions?: { - hideQuickBet?: boolean - hideGroupLink?: boolean - } -}) { - const { - querySortOptions, - onContractClick, - overrideGridClassName, - cardHideOptions, - excludeContractIds, - highlightOptions, - } = props - const { initialQuery } = useInitialQueryAndSort(querySortOptions) - - const { query, setQuery, setSort } = useUpdateQueryAndSort({ - shouldLoadFromStorage: true, - }) - - useEffect(() => { - setQuery(initialQuery) - }, [initialQuery]) - - const { currentRefinement: index } = useSortBy({ - items: [], - }) - - useEffect(() => { - setQuery(query) - }, [query]) - - const isFirstRender = useRef(true) - useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false - return - } - - const sort = index.split('contracts-')[1] as Sort - if (sort) { - setSort(sort) - } - }, [index]) - - const [isInitialLoad, setIsInitialLoad] = useState(true) - useEffect(() => { - const id = setTimeout(() => setIsInitialLoad(false), 1000) - return () => clearTimeout(id) - }, []) - - const { showMore, hits, isLastPage } = useInfiniteHits() - let contracts = hits as any as Contract[] - - if (isInitialLoad && contracts.length === 0) return <></> - - const showTime = index.endsWith('close-date') - ? 'close-date' - : index.endsWith('resolve-date') - ? 'resolve-date' - : undefined - - if (excludeContractIds) - contracts = contracts.filter((c) => !excludeContractIds.includes(c.id)) - - return ( - <ContractsGrid - contracts={contracts} - loadMore={showMore} - hasMore={!isLastPage} - showTime={showTime} - onContractClick={onContractClick} - overrideGridClassName={overrideGridClassName} - highlightOptions={highlightOptions} - cardHideOptions={cardHideOptions} - /> + </Col> ) } diff --git a/web/components/contract/contract-card-preview.tsx b/web/components/contract/contract-card-preview.tsx new file mode 100644 index 00000000..354fe308 --- /dev/null +++ b/web/components/contract/contract-card-preview.tsx @@ -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, + } +} diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 164f3f27..4ef90884 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -30,7 +30,7 @@ import { useContractWithPreload } from 'web/hooks/use-contract' import { useUser } from 'web/hooks/use-user' import { track } from '@amplitude/analytics-browser' import { trackCallback } from 'web/lib/service/analytics' -import { formatNumericProbability } from 'common/pseudo-numeric' +import { getMappedValue } from 'common/pseudo-numeric' export function ContractCard(props: { contract: Contract @@ -115,7 +115,8 @@ export function ContractCard(props: { {question} </p> - {outcomeType === 'FREE_RESPONSE' && + {(outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE') && (resolution ? ( <FreeResponseOutcomeLabel contract={contract} @@ -158,7 +159,8 @@ export function ContractCard(props: { /> )} - {outcomeType === 'FREE_RESPONSE' && ( + {(outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE') && ( <FreeResponseResolutionOrChance className="self-end text-gray-600" contract={contract} @@ -210,7 +212,7 @@ export function BinaryResolutionOrChance(props: { } function FreeResponseTopAnswer(props: { - contract: FreeResponseContract + contract: FreeResponseContract | MultipleChoiceContract truncate: 'short' | 'long' | 'none' className?: string }) { @@ -315,6 +317,12 @@ export function PseudoNumericResolutionOrExpectation(props: { const { resolution, resolutionValue, resolutionProbability } = contract const textColor = `text-blue-400` + const value = resolution + ? resolutionValue + ? resolutionValue + : getMappedValue(contract)(resolutionProbability ?? 0) + : getMappedValue(contract)(getProbability(contract)) + return ( <Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}> {resolution ? ( @@ -324,20 +332,21 @@ export function PseudoNumericResolutionOrExpectation(props: { {resolution === 'CANCEL' ? ( <CancelLabel /> ) : ( - <div className="text-blue-400"> - {resolutionValue - ? formatLargeNumber(resolutionValue) - : formatNumericProbability( - resolutionProbability ?? 0, - contract - )} + <div + className={clsx('tooltip', textColor)} + data-tip={value.toFixed(2)} + > + {formatLargeNumber(value)} </div> )} </> ) : ( <> - <div className={clsx('text-3xl', textColor)}> - {formatNumericProbability(getProbability(contract), contract)} + <div + className={clsx('tooltip text-3xl', textColor)} + data-tip={value.toFixed(2)} + > + {formatLargeNumber(value)} </div> <div className={clsx('text-base', textColor)}>expected</div> </> diff --git a/web/components/contract/contract-description.tsx b/web/components/contract/contract-description.tsx index f9db0cd9..4c9b77a2 100644 --- a/web/components/contract/contract-description.tsx +++ b/web/components/contract/contract-description.tsx @@ -13,6 +13,7 @@ import { TextEditor, useTextEditor } from 'web/components/editor' import { Button } from '../button' import { Spacer } from '../layout/spacer' import { Editor, Content as ContentType } from '@tiptap/react' +import { appendToEditor } from '../editor/utils' export function ContractDescription(props: { contract: Contract @@ -94,12 +95,7 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) { size="xs" onClick={() => { setEditing(true) - editor - ?.chain() - .setContent(contract.description) - .focus('end') - .insertContent(`<p>${editTimestamp()}</p>`) - .run() + appendToEditor(editor, `<p>${editTimestamp()}</p>`) }} > Edit description @@ -131,7 +127,7 @@ function EditQuestion(props: { function joinContent(oldContent: ContentType, newContent: string) { const editor = new Editor({ content: oldContent, extensions: exhibitExts }) - editor.chain().focus('end').insertContent(newContent).run() + appendToEditor(editor, newContent) return editor.getJSON() } diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 7a7242a0..90b5f3d1 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -5,13 +5,13 @@ import { TrendingUpIcon, UserGroupIcon, } from '@heroicons/react/outline' + import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' import { UserLink } from '../user-page' import { Contract, contractMetrics, - contractPath, updateContract, } from 'web/lib/firebase/contracts' import dayjs from 'dayjs' @@ -24,17 +24,16 @@ import { Bet } from 'common/bet' import NewContractBadge from '../new-contract-badge' import { UserFollowButton } from '../follow-button' import { DAY_MS } from 'common/util/time' -import { ShareIconButton } from 'web/components/share-icon-button' import { useUser } from 'web/hooks/use-user' import { Editor } from '@tiptap/react' import { exhibitExts } from 'common/util/parse' -import { ENV_CONFIG } from 'common/envs/constants' import { Button } from 'web/components/button' import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' import { ContractGroupsList } from 'web/components/groups/contract-groups-list' import { SiteLink } from 'web/components/site-link' import { groupPath } from 'web/lib/firebase/groups' +import { appendToEditor } from '../editor/utils' export type ShowTime = 'resolve-date' | 'close-date' @@ -147,6 +146,15 @@ export function ContractDetails(props: { const user = useUser() const [open, setOpen] = useState(false) + const groupInfo = ( + <Row> + <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> + <span className={'line-clamp-1'}> + {groupToDisplay ? groupToDisplay.name : 'No group'} + </span> + </Row> + ) + return ( <Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500"> <Row className="items-center gap-2"> @@ -168,19 +176,18 @@ export function ContractDetails(props: { {!disabled && <UserFollowButton userId={creatorId} small />} </Row> <Row> - <Button - size={'xs'} - className={'max-w-[200px]'} - color={'gray-white'} - onClick={() => setOpen(!open)} - > - <Row> - <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> - <span className={'line-clamp-1'}> - {groupToDisplay ? groupToDisplay.name : 'No group'} - </span> - </Row> - </Button> + {disabled ? ( + groupInfo + ) : ( + <Button + size={'xs'} + className={'max-w-[200px]'} + color={'gray-white'} + onClick={() => setOpen(!open)} + > + {groupInfo} + </Button> + )} </Row> <Modal open={open} setOpen={setOpen} size={'md'}> <Col @@ -228,14 +235,6 @@ export function ContractDetails(props: { <div className="whitespace-nowrap">{volumeLabel}</div> </Row> - <ShareIconButton - copyPayload={`https://${ENV_CONFIG.domain}${contractPath(contract)}${ - user?.username && contract.creatorUsername !== user?.username - ? '?referrer=' + user?.username - : '' - }`} - toastClassName={'sm:-left-40 -left-24 min-w-[250%]'} - /> {!disabled && <ContractInfoDialog contract={contract} bets={bets} />} </Row> @@ -284,12 +283,10 @@ function EditableCloseDate(props: { const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a') const editor = new Editor({ content, extensions: exhibitExts }) - editor - .chain() - .focus('end') - .insertContent('<br /><br />') - .insertContent(`Close date updated to ${formattedCloseDate}`) - .run() + appendToEditor( + editor, + `<br><p>Close date updated to ${formattedCloseDate}</p>` + ) updateContract(contract.id, { closeTime: newCloseTime, diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index a1f79479..168ada50 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -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}` -} diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index deb9b857..6f1a778d 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -107,7 +107,6 @@ export function ContractTopTrades(props: { comment={commentsById[topCommentId]} tips={tips[topCommentId]} betsBySameUser={[betsById[topCommentId]]} - truncate={false} smallAvatar={false} /> </div> diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 50c5a7e6..b95bb02b 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -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} diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index fbf056e3..5aee7899 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -23,6 +23,9 @@ export function ContractTabs(props: { const { outcomeType } = contract const userBets = user && bets.filter((bet) => bet.userId === user.id) + const visibleBets = bets.filter( + (bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0 + ) // Load comments here, so the badge count will be correct const updatedComments = useComments(contract.id) @@ -99,7 +102,7 @@ export function ContractTabs(props: { content: commentActivity, badge: `${comments.length}`, }, - { title: 'Bets', content: betActivity, badge: `${bets.length}` }, + { title: 'Bets', content: betActivity, badge: `${visibleBets.length}` }, ...(!user || !userBets?.length ? [] : [{ title: 'Your bets', content: yourTrades }]), diff --git a/web/components/contract/contracts-list.tsx b/web/components/contract/contracts-grid.tsx similarity index 94% rename from web/components/contract/contracts-list.tsx rename to web/components/contract/contracts-grid.tsx index c733bd76..31a564d3 100644 --- a/web/components/contract/contracts-list.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -8,6 +8,7 @@ import { ContractSearch } from '../contract-search' import { useIsVisible } from 'web/hooks/use-is-visible' import { useEffect, useState } from 'react' import clsx from 'clsx' +import { LoadingIndicator } from '../loading-indicator' export type ContractHighlightOptions = { contractIds?: string[] @@ -15,7 +16,7 @@ export type ContractHighlightOptions = { } export function ContractsGrid(props: { - contracts: Contract[] + contracts: Contract[] | undefined loadMore: () => void hasMore: boolean showTime?: ShowTime @@ -49,6 +50,10 @@ export function ContractsGrid(props: { } }, [isBottomVisible, hasMore, loadMore]) + if (contracts === undefined) { + return <LoadingIndicator /> + } + if (contracts.length === 0) { return ( <p className="mx-2 text-gray-500"> diff --git a/web/components/contract/share-modal.tsx b/web/components/contract/share-modal.tsx new file mode 100644 index 00000000..017d3174 --- /dev/null +++ b/web/components/contract/share-modal.tsx @@ -0,0 +1,77 @@ +import { LinkIcon } from '@heroicons/react/outline' +import toast from 'react-hot-toast' + +import { Contract } from 'common/contract' +import { contractPath } from 'web/lib/firebase/contracts' +import { Col } from '../layout/col' +import { Modal } from '../layout/modal' +import { Row } from '../layout/row' +import { ShareEmbedButton } from '../share-embed-button' +import { Title } from '../title' +import { TweetButton } from '../tweet-button' +import { DuplicateContractButton } from '../copy-contract-button' +import { Button } from '../button' +import { copyToClipboard } from 'web/lib/util/copy' +import { track } from 'web/lib/service/analytics' +import { ENV_CONFIG } from 'common/envs/constants' +import { User } from 'common/user' + +export function ShareModal(props: { + contract: Contract + user: User | undefined | null + isOpen: boolean + setOpen: (open: boolean) => void +}) { + const { contract, user, isOpen, setOpen } = props + + const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> + + const copyPayload = `https://${ENV_CONFIG.domain}${contractPath(contract)}${ + user?.username && contract.creatorUsername !== user?.username + ? '?referrer=' + user?.username + : '' + }` + + return ( + <Modal open={isOpen} setOpen={setOpen}> + <Col className="gap-4 rounded bg-white p-4"> + <Title className="!mt-0 mb-2" text="Share this market" /> + + <Button + size="2xl" + color="gradient" + className={'mb-2 flex max-w-xs self-center'} + onClick={() => { + copyToClipboard(copyPayload) + track('copy share link') + toast.success('Link copied!', { + icon: linkIcon, + }) + }} + > + {linkIcon} Copy link + </Button> + + <Row className="justify-start gap-4 self-center"> + <TweetButton + className="self-start" + tweetText={getTweetText(contract)} + /> + <ShareEmbedButton contract={contract} toastClassName={'-left-20'} /> + <DuplicateContractButton contract={contract} /> + </Row> + </Col> + </Modal> + ) +} + +const getTweetText = (contract: Contract) => { + const { question, resolution } = contract + + const tweetDescription = resolution ? `\n\nResolved ${resolution}!` : '' + + const timeParam = `${Date.now()}`.substring(7) + const url = `https://manifold.markets${contractPath(contract)}?t=${timeParam}` + + return `${question}\n\n${url}${tweetDescription}` +} diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx new file mode 100644 index 00000000..9011ff1b --- /dev/null +++ b/web/components/contract/share-row.tsx @@ -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> + ) +} diff --git a/web/components/copy-link-button.tsx b/web/components/copy-link-button.tsx index 4ce4140d..f3489f3d 100644 --- a/web/components/copy-link-button.tsx +++ b/web/components/copy-link-button.tsx @@ -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 diff --git a/web/components/editor.tsx b/web/components/editor.tsx index bea98b36..36573d0d 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -46,14 +46,16 @@ export function useTextEditor(props: { max?: number defaultValue?: Content disabled?: boolean + simple?: boolean }) { - const { placeholder, max, defaultValue = '', disabled } = props + const { placeholder, max, defaultValue = '', disabled, simple } = props const users = useUsers() const editorClass = clsx( proseClass, - 'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0' + !simple && 'min-h-[6em]', + 'outline-none pt-2 px-4' ) const editor = useEditor( @@ -61,7 +63,8 @@ export function useTextEditor(props: { editorProps: { attributes: { class: editorClass } }, extensions: [ StarterKit.configure({ - heading: { levels: [1, 2, 3] }, + heading: simple ? false : { levels: [1, 2, 3] }, + horizontalRule: simple ? false : {}, }), Placeholder.configure({ placeholder, @@ -125,8 +128,9 @@ function isValidIframe(text: string) { export function TextEditor(props: { editor: Editor | null upload: ReturnType<typeof useUploadMutation> + children?: React.ReactNode // additional toolbar buttons }) { - const { editor, upload } = props + const { editor, upload, children } = props const [iframeOpen, setIframeOpen] = useState(false) const [marketOpen, setMarketOpen] = useState(false) @@ -139,30 +143,13 @@ export function TextEditor(props: { editor={editor} className={clsx(proseClass, '-ml-2 mr-2 w-full text-slate-300 ')} > - Type <em>*markdown*</em>. Paste or{' '} - <FileUploadButton - className="link text-blue-300" - onFiles={upload.mutate} - > - upload - </FileUploadButton>{' '} - images! + Type <em>*markdown*</em> </FloatingMenu> )} - <div className="overflow-hidden rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"> + <div className="rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"> <EditorContent editor={editor} /> - {/* Spacer element to match the height of the toolbar */} - <div className="py-2" aria-hidden="true"> - {/* Matches height of button in toolbar (1px border + 36px content height) */} - <div className="py-px"> - <div className="h-9" /> - </div> - </div> - </div> - - {/* Toolbar, with buttons for image and embeds */} - <div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2"> - <div className="flex items-center space-x-5"> + {/* Toolbar, with buttons for images and embeds */} + <div className="flex h-9 items-center gap-5 pl-4 pr-1"> <div className="tooltip flex items-center" data-tip="Add image"> <FileUploadButton onFiles={upload.mutate} @@ -202,6 +189,8 @@ export function TextEditor(props: { /> </button> </div> + <div className="ml-auto" /> + {children} </div> </div> </div> diff --git a/web/components/editor/mention.tsx b/web/components/editor/mention.tsx index 3ad5de39..5ccea6f5 100644 --- a/web/components/editor/mention.tsx +++ b/web/components/editor/mention.tsx @@ -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' }), }) diff --git a/web/components/editor/utils.ts b/web/components/editor/utils.ts new file mode 100644 index 00000000..74af38c5 --- /dev/null +++ b/web/components/editor/utils.ts @@ -0,0 +1,10 @@ +import { Editor, Content } from '@tiptap/react' + +export function appendToEditor(editor: Editor | null, content: Content) { + editor + ?.chain() + .focus('end') + .createParagraphNear() + .insertContent(content) + .run() +} diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index b1c8f6ee..cd490701 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -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 ) diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index aabb1081..edaf1fe5 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -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> )} diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index 408404ba..29645136 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -10,11 +10,14 @@ import { UsersIcon } from '@heroicons/react/solid' import { formatMoney, formatPercent } from 'common/util/format' import { OutcomeLabel } from 'web/components/outcome-label' import { RelativeTimestamp } from 'web/components/relative-timestamp' -import React, { Fragment } from 'react' +import React, { Fragment, useEffect } from 'react' import { uniqBy, partition, sumBy, groupBy } from 'lodash' import { JoinSpans } from 'web/components/join-spans' import { UserLink } from '../user-page' import { formatNumericProbability } from 'common/pseudo-numeric' +import { SiteLink } from 'web/components/site-link' +import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' +import { Challenge } from 'common/challenge' export function FeedBet(props: { contract: Contract @@ -79,7 +82,15 @@ export function BetStatusText(props: { const { outcomeType } = contract const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isFreeResponse = outcomeType === 'FREE_RESPONSE' - const { amount, outcome, createdTime } = bet + const { amount, outcome, createdTime, challengeSlug } = bet + const [challenge, setChallenge] = React.useState<Challenge>() + useEffect(() => { + if (challengeSlug) { + getChallenge(challengeSlug, contract.id).then((c) => { + setChallenge(c) + }) + } + }, [challengeSlug, contract.id]) const bought = amount >= 0 ? 'bought' : 'sold' const outOfTotalAmount = @@ -133,6 +144,14 @@ export function BetStatusText(props: { {fromProb === toProb ? `at ${fromProb}` : `from ${fromProb} to ${toProb}`} + {challengeSlug && ( + <SiteLink + href={challenge ? getChallengeUrl(challenge) : ''} + className={'mx-1'} + > + [challenge] + </SiteLink> + )} </> )} <RelativeTimestamp time={createdTime} /> diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index f4c6eb74..8c84039e 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -13,25 +13,22 @@ import { Avatar } from 'web/components/avatar' import { UserLink } from 'web/components/user-page' import { OutcomeLabel } from 'web/components/outcome-label' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' -import { contractPath } from 'web/lib/firebase/contracts' import { firebaseLogin } from 'web/lib/firebase/users' import { createCommentOnContract, MAX_COMMENT_LENGTH, } from 'web/lib/firebase/comments' -import Textarea from 'react-expanding-textarea' -import { Linkify } from 'web/components/linkify' -import { SiteLink } from 'web/components/site-link' import { BetStatusText } from 'web/components/feed/feed-bets' import { Col } from 'web/components/layout/col' import { getProbability } from 'common/calculate' import { LoadingIndicator } from 'web/components/loading-indicator' import { PaperAirplaneIcon } from '@heroicons/react/outline' import { track } from 'web/lib/service/analytics' -import { useEvent } from 'web/hooks/use-event' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { useWindowSize } from 'web/hooks/use-window-size' +import { Content, TextEditor, useTextEditor } from '../editor' +import { Editor } from '@tiptap/react' export function FeedCommentThread(props: { contract: Contract @@ -39,20 +36,12 @@ export function FeedCommentThread(props: { tips: CommentTipMap parentComment: Comment bets: Bet[] - truncate?: boolean smallAvatar?: boolean }) { - const { - contract, - comments, - bets, - tips, - truncate, - smallAvatar, - parentComment, - } = props + const { contract, comments, bets, tips, smallAvatar, parentComment } = props const [showReply, setShowReply] = useState(false) - const [replyToUsername, setReplyToUsername] = useState('') + const [replyToUser, setReplyToUser] = + useState<{ id: string; username: string }>() const betsByUserId = groupBy(bets, (bet) => bet.userId) const user = useUser() const commentsList = comments.filter( @@ -60,15 +49,12 @@ export function FeedCommentThread(props: { parentComment.id && comment.replyToCommentId === parentComment.id ) commentsList.unshift(parentComment) - const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) + function scrollAndOpenReplyInput(comment: Comment) { - setReplyToUsername(comment.userUsername) + setReplyToUser({ id: comment.userId, username: comment.userUsername }) setShowReply(true) - inputRef?.focus() } - useEffect(() => { - if (showReply && inputRef) inputRef.focus() - }, [inputRef, showReply]) + return ( <Col className={'w-full gap-3 pr-1'}> <span @@ -81,7 +67,6 @@ export function FeedCommentThread(props: { betsByUserId={betsByUserId} tips={tips} smallAvatar={smallAvatar} - truncate={truncate} bets={bets} scrollAndOpenReplyInput={scrollAndOpenReplyInput} /> @@ -98,13 +83,9 @@ export function FeedCommentThread(props: { (c) => c.userId === user?.id )} parentCommentId={parentComment.id} - replyToUsername={replyToUsername} + replyToUser={replyToUser} parentAnswerOutcome={comments[0].answerOutcome} - setRef={setInputRef} - onSubmitComment={() => { - setShowReply(false) - setReplyToUsername('') - }} + onSubmitComment={() => setShowReply(false)} /> </Col> )} @@ -121,14 +102,12 @@ export function CommentRepliesList(props: { bets: Bet[] treatFirstIndexEqually?: boolean smallAvatar?: boolean - truncate?: boolean }) { const { contract, commentsList, betsByUserId, tips, - truncate, smallAvatar, bets, scrollAndOpenReplyInput, @@ -168,7 +147,6 @@ export function CommentRepliesList(props: { : undefined } smallAvatar={smallAvatar} - truncate={truncate} /> </div> ))} @@ -182,7 +160,6 @@ export function FeedComment(props: { tips: CommentTips betsBySameUser: Bet[] probAtCreatedTime?: number - truncate?: boolean smallAvatar?: boolean onReplyClick?: (comment: Comment) => void }) { @@ -192,10 +169,10 @@ export function FeedComment(props: { tips, betsBySameUser, probAtCreatedTime, - truncate, onReplyClick, } = props - const { text, userUsername, userName, userAvatarUrl, createdTime } = comment + const { text, content, userUsername, userName, userAvatarUrl, createdTime } = + comment let betOutcome: string | undefined, bought: string | undefined, money: string | undefined @@ -276,11 +253,9 @@ export function FeedComment(props: { elementId={comment.id} /> </div> - <TruncatedComment - comment={text} - moreHref={contractPath(contract)} - shouldTruncate={truncate} - /> + <div className="mt-2 text-[15px] text-gray-700"> + <Content content={content || text} /> + </div> <Row className="mt-2 items-center gap-6 text-xs text-gray-500"> <Tipper comment={comment} tips={tips ?? {}} /> {onReplyClick && ( @@ -345,8 +320,7 @@ export function CommentInput(props: { contract: Contract betsByCurrentUser: Bet[] commentsByCurrentUser: Comment[] - replyToUsername?: string - setRef?: (ref: HTMLTextAreaElement) => void + replyToUser?: { id: string; username: string } // Reply to a free response answer parentAnswerOutcome?: string // Reply to another comment @@ -359,12 +333,18 @@ export function CommentInput(props: { commentsByCurrentUser, parentAnswerOutcome, parentCommentId, - replyToUsername, + replyToUser, onSubmitComment, - setRef, } = props const user = useUser() - const [comment, setComment] = useState('') + const { editor, upload } = useTextEditor({ + simple: true, + max: MAX_COMMENT_LENGTH, + placeholder: + !!parentCommentId || !!parentAnswerOutcome + ? 'Write a reply...' + : 'Write a comment...', + }) const [isSubmitting, setIsSubmitting] = useState(false) const mostRecentCommentableBet = getMostRecentCommentableBet( @@ -380,18 +360,17 @@ export function CommentInput(props: { track('sign in to comment') return await firebaseLogin() } - if (!comment || isSubmitting) return + if (!editor || editor.isEmpty || isSubmitting) return setIsSubmitting(true) await createCommentOnContract( contract.id, - comment, + editor.getJSON(), user, betId, parentAnswerOutcome, parentCommentId ) onSubmitComment?.() - setComment('') setIsSubmitting(false) } @@ -446,14 +425,12 @@ export function CommentInput(props: { )} </div> <CommentInputTextArea - commentText={comment} - setComment={setComment} - isReply={!!parentCommentId || !!parentAnswerOutcome} - replyToUsername={replyToUsername ?? ''} + editor={editor} + upload={upload} + replyToUser={replyToUser} user={user} submitComment={submitComment} isSubmitting={isSubmitting} - setRef={setRef} presetId={id} /> </div> @@ -465,94 +442,93 @@ export function CommentInput(props: { export function CommentInputTextArea(props: { user: User | undefined | null - isReply: boolean - replyToUsername: string - commentText: string - setComment: (text: string) => void + replyToUser?: { id: string; username: string } + editor: Editor | null + upload: Parameters<typeof TextEditor>[0]['upload'] submitComment: (id?: string) => void isSubmitting: boolean - setRef?: (ref: HTMLTextAreaElement) => void + submitOnEnter?: boolean presetId?: string - enterToSubmitOnDesktop?: boolean }) { const { - isReply, - setRef, user, - commentText, - setComment, + editor, + upload, submitComment, presetId, isSubmitting, - replyToUsername, - enterToSubmitOnDesktop, + submitOnEnter, + replyToUser, } = props - const { width } = useWindowSize() - const memoizedSetComment = useEvent(setComment) + const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch) + useEffect(() => { - if (!replyToUsername || !user || replyToUsername === user.username) return - const replacement = `@${replyToUsername} ` - memoizedSetComment(replacement + commentText.replace(replacement, '')) + editor?.setEditable(!isSubmitting) + }, [isSubmitting, editor]) + + const submit = () => { + submitComment(presetId) + editor?.commands?.clearContent() + } + + useEffect(() => { + if (!editor) { + return + } + // submit on Enter key + editor.setOptions({ + editorProps: { + handleKeyDown: (view, event) => { + if ( + submitOnEnter && + event.key === 'Enter' && + !event.shiftKey && + (!isMobile || event.ctrlKey || event.metaKey) && + // mention list is closed + !(view.state as any).mention$.active + ) { + submit() + event.preventDefault() + return true + } + return false + }, + }, + }) + // insert at mention and focus + if (replyToUser) { + editor + .chain() + .setContent({ + type: 'mention', + attrs: { label: replyToUser.username, id: replyToUser.id }, + }) + .insertContent(' ') + .focus() + .run() + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user, replyToUsername, memoizedSetComment]) + }, [editor]) + return ( <> - <Row className="gap-1.5 text-gray-700"> - <Textarea - ref={setRef} - value={commentText} - onChange={(e) => setComment(e.target.value)} - className={clsx('textarea textarea-bordered w-full resize-none')} - // Make room for floating submit button. - style={{ paddingRight: 48 }} - placeholder={ - isReply - ? 'Write a reply... ' - : enterToSubmitOnDesktop - ? 'Send a message' - : 'Write a comment...' - } - autoFocus={false} - maxLength={MAX_COMMENT_LENGTH} - disabled={isSubmitting} - onKeyDown={(e) => { - if ( - (enterToSubmitOnDesktop && - e.key === 'Enter' && - !e.shiftKey && - width && - width > 768) || - (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) - ) { - e.preventDefault() - submitComment(presetId) - e.currentTarget.blur() - } - }} - /> - - <Col className={clsx('relative justify-end')}> + <div> + <TextEditor editor={editor} upload={upload}> {user && !isSubmitting && ( <button - className={clsx( - 'btn btn-ghost btn-sm absolute right-2 bottom-2 flex-row pl-2 capitalize', - !commentText && 'pointer-events-none text-gray-500' - )} - onClick={() => { - submitComment(presetId) - }} + className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300" + disabled={!editor || editor.isEmpty} + onClick={submit} > - <PaperAirplaneIcon - className={'m-0 min-w-[22px] rotate-90 p-0 '} - height={25} - /> + <PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" /> </button> )} + {isSubmitting && ( <LoadingIndicator spinnerClassName={'border-gray-500'} /> )} - </Col> - </Row> + </TextEditor> + </div> <Row> {!user && ( <button @@ -567,38 +543,6 @@ export function CommentInputTextArea(props: { ) } -export function TruncatedComment(props: { - comment: string - moreHref: string - shouldTruncate?: boolean -}) { - const { comment, moreHref, shouldTruncate } = props - let truncated = comment - - // Keep descriptions to at most 400 characters - const MAX_CHARS = 400 - if (shouldTruncate && truncated.length > MAX_CHARS) { - truncated = truncated.slice(0, MAX_CHARS) - // Make sure to end on a space - const i = truncated.lastIndexOf(' ') - truncated = truncated.slice(0, i) - } - - return ( - <div - className="mt-2 whitespace-pre-line break-words text-gray-700" - style={{ fontSize: 15 }} - > - <Linkify text={truncated} /> - {truncated != comment && ( - <SiteLink href={moreHref} className="text-indigo-700"> - ... (show more) - </SiteLink> - )} - </div> - ) -} - function getBettorsLargestPositionBeforeTime( contract: Contract, createdTime: number, diff --git a/web/components/groups/contract-groups-list.tsx b/web/components/groups/contract-groups-list.tsx index 423cbb97..79f2390f 100644 --- a/web/components/groups/contract-groups-list.tsx +++ b/web/components/groups/contract-groups-list.tsx @@ -7,6 +7,7 @@ import { Button } from 'web/components/button' import { GroupSelector } from 'web/components/groups/group-selector' import { addContractToGroup, + canModifyGroupContracts, removeContractFromGroup, } from 'web/lib/firebase/groups' import { User } from 'common/user' @@ -57,11 +58,11 @@ export function ContractGroupsList(props: { <Row className="line-clamp-1 items-center gap-2"> <GroupLinkItem group={group} /> </Row> - {user && group.memberIds.includes(user.id) && ( + {user && canModifyGroupContracts(group, user.id) && ( <Button color={'gray-white'} size={'xs'} - onClick={() => removeContractFromGroup(group, contract)} + onClick={() => removeContractFromGroup(group, contract, user.id)} > <XIcon className="h-4 w-4 text-gray-500" /> </Button> diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 2cf2d73d..2d25351a 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -1,28 +1,26 @@ import { Row } from 'web/components/layout/row' import { Col } from 'web/components/layout/col' -import { User } from 'common/user' +import { PrivateUser, User } from 'common/user' import React, { useEffect, memo, useState, useMemo } from 'react' import { Avatar } from 'web/components/avatar' import { Group } from 'common/group' import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments' -import { - CommentInputTextArea, - TruncatedComment, -} from 'web/components/feed/feed-comments' +import { CommentInputTextArea } from 'web/components/feed/feed-comments' import { track } from 'web/lib/service/analytics' import { firebaseLogin } from 'web/lib/firebase/users' - import { useRouter } from 'next/router' import clsx from 'clsx' import { UserLink } from 'web/components/user-page' - -import { groupPath } from 'web/lib/firebase/groups' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { Tipper } from 'web/components/tipper' import { sum } from 'lodash' import { formatMoney } from 'common/util/format' import { useWindowSize } from 'web/hooks/use-window-size' +import { Content, useTextEditor } from 'web/components/editor' +import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' +import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline' +import { setNotificationsAsSeen } from 'web/pages/notifications' export function GroupChat(props: { messages: Comment[] @@ -31,38 +29,48 @@ export function GroupChat(props: { tips: CommentTipMap }) { const { messages, user, group, tips } = props - const [messageText, setMessageText] = useState('') + const { editor, upload } = useTextEditor({ + simple: true, + placeholder: 'Send a message', + }) const [isSubmitting, setIsSubmitting] = useState(false) const [scrollToBottomRef, setScrollToBottomRef] = useState<HTMLDivElement | null>(null) const [scrollToMessageId, setScrollToMessageId] = useState('') const [scrollToMessageRef, setScrollToMessageRef] = useState<HTMLDivElement | null>(null) - const [replyToUsername, setReplyToUsername] = useState('') - const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) - const [groupedMessages, setGroupedMessages] = useState<Comment[]>([]) + const [replyToUser, setReplyToUser] = useState<any>() + const router = useRouter() const isMember = user && group.memberIds.includes(user?.id) - useMemo(() => { + const { width, height } = useWindowSize() + const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) + // Subtract bottom bar when it's showing (less than lg screen) + const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0 + const remainingHeight = + (height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight + + // array of groups, where each group is an array of messages that are displayed as one + const groupedMessages = useMemo(() => { // Group messages with createdTime within 2 minutes of each other. - const tempMessages = [] + const tempGrouped: Comment[][] = [] for (let i = 0; i < messages.length; i++) { const message = messages[i] - if (i === 0) tempMessages.push({ ...message }) + if (i === 0) tempGrouped.push([message]) else { const prevMessage = messages[i - 1] const diff = message.createdTime - prevMessage.createdTime const creatorsMatch = message.userId === prevMessage.userId if (diff < 2 * 60 * 1000 && creatorsMatch) { - tempMessages[tempMessages.length - 1].text += `\n${message.text}` + tempGrouped.at(-1)?.push(message) } else { - tempMessages.push({ ...message }) + tempGrouped.push([message]) } } } - setGroupedMessages(tempMessages) + return tempGrouped }, [messages]) useEffect(() => { @@ -70,9 +78,10 @@ export function GroupChat(props: { }, [scrollToMessageRef]) useEffect(() => { - if (!isSubmitting) - scrollToBottomRef?.scrollTo({ top: scrollToBottomRef?.scrollHeight || 0 }) - }, [scrollToBottomRef, isSubmitting]) + if (scrollToBottomRef) + scrollToBottomRef.scrollTo({ top: scrollToBottomRef.scrollHeight || 0 }) + // Must also listen to groupedMessages as they update the height of the messaging window + }, [scrollToBottomRef, groupedMessages]) useEffect(() => { const elementInUrl = router.asPath.split('#')[1] @@ -81,8 +90,14 @@ export function GroupChat(props: { } }, [messages, router.asPath]) + useEffect(() => { + // is mobile? + if (width && width > 720) focusInput() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [width]) + function onReplyClick(comment: Comment) { - setReplyToUsername(comment.userUsername) + setReplyToUser({ id: comment.userId, username: comment.userUsername }) } async function submitMessage() { @@ -90,27 +105,18 @@ export function GroupChat(props: { track('sign in to comment') return await firebaseLogin() } - if (!messageText || isSubmitting) return + if (!editor || editor.isEmpty || isSubmitting) return setIsSubmitting(true) - await createCommentOnGroup(group.id, messageText, user) - setMessageText('') + await createCommentOnGroup(group.id, editor.getJSON(), user) + editor.commands.clearContent() setIsSubmitting(false) - setReplyToUsername('') - inputRef?.focus() + setReplyToUser(undefined) + focusInput() } function focusInput() { - inputRef?.focus() + editor?.commands.focus() } - const { width, height } = useWindowSize() - const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) - // Subtract bottom bar when it's showing (less than lg screen) - const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0 - const remainingHeight = - (height ?? window.innerHeight) - - (containerRef?.offsetTop ?? 0) - - bottomBarHeight - return ( <Col ref={setContainerRef} style={{ height: remainingHeight }}> <Col @@ -119,20 +125,20 @@ export function GroupChat(props: { } ref={setScrollToBottomRef} > - {groupedMessages.map((message) => ( + {groupedMessages.map((messages) => ( <GroupMessage user={user} - key={message.id} - comment={message} + key={`group ${messages[0].id}`} + comments={messages} group={group} onReplyClick={onReplyClick} - highlight={message.id === scrollToMessageId} + highlight={messages[0].id === scrollToMessageId} setRef={ - scrollToMessageId === message.id + scrollToMessageId === messages[0].id ? setScrollToMessageRef : undefined } - tips={tips[message.id] ?? {}} + tips={tips[messages[0].id] ?? {}} /> ))} {messages.length === 0 && ( @@ -140,7 +146,7 @@ export function GroupChat(props: { No messages yet. Why not{isMember ? ` ` : ' join and '} <button className={'cursor-pointer font-bold text-gray-700'} - onClick={() => focusInput()} + onClick={focusInput} > add one? </button> @@ -158,15 +164,13 @@ export function GroupChat(props: { </div> <div className={'flex-1'}> <CommentInputTextArea - commentText={messageText} - setComment={setMessageText} - isReply={false} + editor={editor} + upload={upload} user={user} - replyToUsername={replyToUsername} + replyToUser={replyToUser} submitComment={submitMessage} isSubmitting={isSubmitting} - enterToSubmitOnDesktop={true} - setRef={setInputRef} + submitOnEnter /> </div> </div> @@ -175,18 +179,131 @@ export function GroupChat(props: { ) } +export function GroupChatInBubble(props: { + messages: Comment[] + user: User | null | undefined + privateUser: PrivateUser | null | undefined + group: Group + tips: CommentTipMap +}) { + const { messages, user, group, tips, privateUser } = props + const [shouldShowChat, setShouldShowChat] = useState(false) + const router = useRouter() + + useEffect(() => { + const groupsWithChatEmphasis = [ + 'welcome', + 'bugs', + 'manifold-features-25bad7c7792e', + 'updates', + ] + if ( + router.asPath.includes('/chat') || + groupsWithChatEmphasis.includes( + router.asPath.split('/group/')[1].split('/')[0] + ) + ) { + setShouldShowChat(true) + } + // Leave chat open between groups if user is using chat? + else { + setShouldShowChat(false) + } + }, [router.asPath]) + + return ( + <Col + className={clsx( + 'fixed right-0 bottom-[0px] h-1 w-full sm:bottom-[20px] sm:right-20 sm:w-2/3 md:w-1/2 lg:right-24 lg:w-1/3 xl:right-32 xl:w-1/4', + shouldShowChat ? 'p-2m z-10 h-screen bg-white' : '' + )} + > + {shouldShowChat && ( + <GroupChat messages={messages} user={user} group={group} tips={tips} /> + )} + <button + type="button" + className={clsx( + 'fixed right-1 inline-flex items-center rounded-full border md:right-2 lg:right-5 xl:right-10' + + ' border-transparent p-3 text-white shadow-sm lg:p-4' + + ' focus:outline-none focus:ring-2 focus:ring-offset-2 ' + + ' bottom-[70px] ', + shouldShowChat + ? 'bottom-auto top-2 bg-gray-600 hover:bg-gray-400 focus:ring-gray-500 sm:bottom-[70px] sm:top-auto ' + : ' bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500' + )} + onClick={() => { + // router.push('/chat') + setShouldShowChat(!shouldShowChat) + track('mobile group chat button') + }} + > + {!shouldShowChat ? ( + <UsersIcon className="h-10 w-10" aria-hidden="true" /> + ) : ( + <ChevronDownIcon className={'h-10 w-10'} aria-hidden={'true'} /> + )} + {privateUser && ( + <GroupChatNotificationsIcon + group={group} + privateUser={privateUser} + shouldSetAsSeen={shouldShowChat} + /> + )} + </button> + </Col> + ) +} + +function GroupChatNotificationsIcon(props: { + group: Group + privateUser: PrivateUser + shouldSetAsSeen: boolean +}) { + const { privateUser, group, shouldSetAsSeen } = props + const preferredNotificationsForThisGroup = useUnseenPreferredNotifications( + privateUser, + { + customHref: `/group/${group.slug}`, + } + ) + useEffect(() => { + preferredNotificationsForThisGroup.forEach((notification) => { + if ( + (shouldSetAsSeen && notification.isSeenOnHref?.includes('chat')) || + // old style chat notif that simply ended with the group slug + notification.isSeenOnHref?.endsWith(group.slug) + ) { + setNotificationsAsSeen([notification]) + } + }) + }, [group.slug, preferredNotificationsForThisGroup, shouldSetAsSeen]) + + return ( + <div + className={ + preferredNotificationsForThisGroup.length > 0 && !shouldSetAsSeen + ? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500' + : 'hidden' + } + ></div> + ) +} + const GroupMessage = memo(function GroupMessage_(props: { user: User | null | undefined - comment: Comment + comments: Comment[] group: Group onReplyClick?: (comment: Comment) => void setRef?: (ref: HTMLDivElement) => void highlight?: boolean tips: CommentTips }) { - const { comment, onReplyClick, group, setRef, highlight, user, tips } = props - const { text, userUsername, userName, userAvatarUrl, createdTime } = comment - const isCreatorsComment = user && comment.userId === user.id + const { comments, onReplyClick, group, setRef, highlight, user, tips } = props + const first = comments[0] + const { id, userUsername, userName, userAvatarUrl, createdTime } = first + + const isCreatorsComment = user && first.userId === user.id return ( <Col ref={setRef} @@ -216,23 +333,21 @@ const GroupMessage = memo(function GroupMessage_(props: { prefix={'group'} slug={group.slug} createdTime={createdTime} - elementId={comment.id} - /> - </Row> - <Row className={'text-black'}> - <TruncatedComment - comment={text} - moreHref={groupPath(group.slug)} - shouldTruncate={false} + elementId={id} /> </Row> + <div className="mt-2 text-black"> + {comments.map((comment) => ( + <Content content={comment.content || comment.text} /> + ))} + </div> <Row> {!isCreatorsComment && onReplyClick && ( <button className={ 'self-start py-1 text-xs font-bold text-gray-500 hover:underline' } - onClick={() => onReplyClick(comment)} + onClick={() => onReplyClick(first)} > Reply </button> @@ -242,7 +357,7 @@ const GroupMessage = memo(function GroupMessage_(props: { {formatMoney(sum(Object.values(tips)))} </span> )} - {!isCreatorsComment && <Tipper comment={comment} tips={tips} />} + {!isCreatorsComment && <Tipper comment={first} tips={tips} />} </Row> </Col> ) diff --git a/web/components/groups/group-selector.tsx b/web/components/groups/group-selector.tsx index e6270a4d..d48256a6 100644 --- a/web/components/groups/group-selector.tsx +++ b/web/components/groups/group-selector.tsx @@ -9,7 +9,7 @@ import { import clsx from 'clsx' import { CreateGroupButton } from 'web/components/groups/create-group-button' import { useState } from 'react' -import { useMemberGroups } from 'web/hooks/use-group' +import { useMemberGroups, useOpenGroups } from 'web/hooks/use-group' import { User } from 'common/user' import { searchInAny } from 'common/util/parse' @@ -27,10 +27,15 @@ export function GroupSelector(props: { const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false) const { showSelector, showLabel, ignoreGroupIds } = options const [query, setQuery] = useState('') - const memberGroups = (useMemberGroups(creator?.id) ?? []).filter( - (group) => !ignoreGroupIds?.includes(group.id) - ) - const filteredGroups = memberGroups.filter((group) => + const openGroups = useOpenGroups() + const availableGroups = openGroups + .concat( + (useMemberGroups(creator?.id) ?? []).filter( + (g) => !openGroups.map((og) => og.id).includes(g.id) + ) + ) + .filter((group) => !ignoreGroupIds?.includes(group.id)) + const filteredGroups = availableGroups.filter((group) => searchInAny(query, group.name) ) diff --git a/web/components/landing-page-panel.tsx b/web/components/landing-page-panel.tsx index bcfdaf1e..4b436442 100644 --- a/web/components/landing-page-panel.tsx +++ b/web/components/landing-page-panel.tsx @@ -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' diff --git a/web/components/liquidity-panel.tsx b/web/components/liquidity-panel.tsx index 7ecadeb7..94cf63b5 100644 --- a/web/components/liquidity-panel.tsx +++ b/web/components/liquidity-panel.tsx @@ -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')} diff --git a/web/components/manalink-card.tsx b/web/components/manalink-card.tsx index 51880f5d..b04fd0da 100644 --- a/web/components/manalink-card.tsx +++ b/web/components/manalink-card.tsx @@ -1,15 +1,18 @@ +import { useState } from 'react' import clsx from 'clsx' +import { QrcodeIcon } from '@heroicons/react/outline' +import { DotsHorizontalIcon } from '@heroicons/react/solid' + import { formatMoney } from 'common/util/format' import { fromNow } from 'web/lib/util/time' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Claim, Manalink } from 'common/manalink' -import { useState } from 'react' import { ShareIconButton } from './share-icon-button' -import { DotsHorizontalIcon } from '@heroicons/react/solid' import { contractDetailsButtonClassName } from './contract/contract-info-dialog' import { useUserById } from 'web/hooks/use-user' import getManalinkUrl from 'web/get-manalink-url' + export type ManalinkInfo = { expiresTime: number | null maxUses: number | null @@ -78,7 +81,9 @@ export function ManalinkCardFromView(props: { const { className, link, highlightedSlug } = props const { message, amount, expiresTime, maxUses, claims } = link const [showDetails, setShowDetails] = useState(false) - + const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${200}x${200}&data=${getManalinkUrl( + link.slug + )}` return ( <Col> <Col @@ -127,6 +132,14 @@ export function ManalinkCardFromView(props: { > {formatMoney(amount)} </div> + + <button + onClick={() => (window.location.href = qrUrl)} + className={clsx(contractDetailsButtonClassName)} + > + <QrcodeIcon className="h-6 w-6" /> + </button> + <ShareIconButton toastClassName={'-left-48 min-w-[250%]'} buttonClassName={'transition-colors'} diff --git a/web/components/manalinks/create-links-button.tsx b/web/components/manalinks/create-links-button.tsx index 656aff29..449d6c76 100644 --- a/web/components/manalinks/create-links-button.tsx +++ b/web/components/manalinks/create-links-button.tsx @@ -12,6 +12,7 @@ import dayjs from 'dayjs' import { Button } from '../button' import { getManalinkUrl } from 'web/pages/links' import { DuplicateIcon } from '@heroicons/react/outline' +import { QRCode } from '../qr-code' export function CreateLinksButton(props: { user: User @@ -98,6 +99,8 @@ function CreateManalinkForm(props: { }) } + const url = getManalinkUrl(highlightedSlug) + return ( <> {!finishedCreating && ( @@ -199,17 +202,17 @@ function CreateManalinkForm(props: { copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : '' )} > - <div className="w-full select-text truncate"> - {getManalinkUrl(highlightedSlug)} - </div> + <div className="w-full select-text truncate">{url}</div> <DuplicateIcon onClick={() => { - navigator.clipboard.writeText(getManalinkUrl(highlightedSlug)) + navigator.clipboard.writeText(url) setCopyPressed(true) }} className="my-auto ml-2 h-5 w-5 cursor-pointer transition hover:opacity-50" /> </Row> + + <QRCode url={url} className="self-center" /> </> )} </> diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 581dd5fa..713bc575 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -18,7 +18,7 @@ import { ManifoldLogo } from './manifold-logo' import { MenuButton } from './menu' import { ProfileSummary } from './profile-menu' import NotificationsIcon from 'web/components/notifications-icon' -import React, { useEffect, useState } from 'react' +import React, { useMemo, useState } from 'react' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { CreateQuestionButton } from 'web/components/create-question-button' import { useMemberGroups } from 'web/hooks/use-group' @@ -27,9 +27,9 @@ import { trackCallback, withTracking } from 'web/lib/service/analytics' import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Spacer } from '../layout/spacer' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' -import { setNotificationsAsSeen } from 'web/pages/notifications' import { PrivateUser } from 'common/user' import { useWindowSize } from 'web/hooks/use-window-size' +import { CHALLENGES_ENABLED } from 'common/challenge' const logout = async () => { // log out, and then reload the page, in case SSR wants to boot them out @@ -61,26 +61,50 @@ function getMoreNavigation(user?: User | null) { } if (!user) { - return [ - { name: 'Charity', href: '/charity' }, - { name: 'Blog', href: 'https://news.manifold.markets' }, - { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, - { name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' }, - ] + if (CHALLENGES_ENABLED) + return [ + { name: 'Challenges', href: '/challenges' }, + { name: 'Charity', href: '/charity' }, + { name: 'Blog', href: 'https://news.manifold.markets' }, + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + { name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' }, + ] + else + return [ + { name: 'Charity', href: '/charity' }, + { name: 'Blog', href: 'https://news.manifold.markets' }, + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + { name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' }, + ] } - return [ - { name: 'Referrals', href: '/referrals' }, - { name: 'Charity', href: '/charity' }, - { name: 'Send M$', href: '/links' }, - { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, - { name: 'About', href: 'https://docs.manifold.markets/$how-to' }, - { - name: 'Sign out', - href: '#', - onClick: logout, - }, - ] + if (CHALLENGES_ENABLED) + return [ + { name: 'Challenges', href: '/challenges' }, + { name: 'Referrals', href: '/referrals' }, + { name: 'Charity', href: '/charity' }, + { name: 'Send M$', href: '/links' }, + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + { name: 'About', href: 'https://docs.manifold.markets/$how-to' }, + { + name: 'Sign out', + href: '#', + onClick: logout, + }, + ] + else + return [ + { name: 'Referrals', href: '/referrals' }, + { name: 'Charity', href: '/charity' }, + { name: 'Send M$', href: '/links' }, + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + { name: 'About', href: 'https://docs.manifold.markets/$how-to' }, + { + name: 'Sign out', + href: '#', + onClick: logout, + }, + ] } const signedOutNavigation = [ @@ -120,6 +144,14 @@ function getMoreMobileNav() { return [ ...(IS_PRIVATE_MANIFOLD ? [] + : CHALLENGES_ENABLED + ? [ + { name: 'Challenges', href: '/challenges' }, + { name: 'Referrals', href: '/referrals' }, + { name: 'Charity', href: '/charity' }, + { name: 'Send M$', href: '/links' }, + { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, + ] : [ { name: 'Referrals', href: '/referrals' }, { name: 'Charity', href: '/charity' }, @@ -216,7 +248,7 @@ export default function Sidebar(props: { className?: string }) { ) ?? [] ).map((group: Group) => ({ name: group.name, - href: `${groupPath(group.slug)}/${GROUP_CHAT_SLUG}`, + href: `${groupPath(group.slug)}`, })) return ( @@ -294,30 +326,22 @@ function GroupsList(props: { memberItems.length > 0 ? memberItems.length : undefined ) - // Set notification as seen if our current page is equal to the isSeenOnHref property - useEffect(() => { - const currentPageWithoutQuery = currentPage.split('?')[0] - const currentPageGroupSlug = currentPageWithoutQuery.split('/')[2] - preferredNotifications.forEach((notification) => { - if ( - notification.isSeenOnHref === currentPage || - // Old chat style group chat notif was just /group/slug - (notification.isSeenOnHref && - currentPageWithoutQuery.includes(notification.isSeenOnHref)) || - // They're on the home page, so if they've a chat notif, they're seeing the chat - (notification.isSeenOnHref?.endsWith(GROUP_CHAT_SLUG) && - currentPageWithoutQuery.endsWith(currentPageGroupSlug)) - ) { - setNotificationsAsSeen([notification]) - } - }) - }, [currentPage, preferredNotifications]) - const { height } = useWindowSize() const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) const remainingHeight = (height ?? window.innerHeight) - (containerRef?.offsetTop ?? 0) + const notifIsForThisItem = useMemo( + () => (itemHref: string) => + preferredNotifications.some( + (n) => + !n.isSeen && + (n.isSeenOnHref === itemHref || + n.isSeenOnHref?.replace('/chat', '') === itemHref) + ), + [preferredNotifications] + ) + return ( <> <SidebarItem @@ -332,19 +356,19 @@ function GroupsList(props: { > {memberItems.map((item) => ( <a - key={item.href} - href={item.href} + href={ + item.href + + (notifIsForThisItem(item.href) ? '/' + GROUP_CHAT_SLUG : '') + } + key={item.name} + onClick={trackCallback('sidebar: ' + item.name)} className={clsx( + 'cursor-pointer truncate', 'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900', - preferredNotifications.some( - (n) => - !n.isSeen && - (n.isSeenOnHref === item.href || - n.isSeenOnHref === item.href.replace('/chat', '')) - ) && 'font-bold' + notifIsForThisItem(item.href) && 'font-bold' )} > - <span className="truncate">{item.name}</span> + {item.name} </a> ))} </div> diff --git a/web/components/onboarding/welcome.tsx b/web/components/onboarding/welcome.tsx new file mode 100644 index 00000000..5a187a24 --- /dev/null +++ b/web/components/onboarding/welcome.tsx @@ -0,0 +1,173 @@ +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid' +import clsx from 'clsx' +import { useState } from 'react' +import { useUser } from 'web/hooks/use-user' +import { updateUser } from 'web/lib/firebase/users' +import { Col } from '../layout/col' +import { Modal } from '../layout/modal' +import { Row } from '../layout/row' +import { Title } from '../title' + +export default function Welcome() { + const user = useUser() + const [open, setOpen] = useState(true) + const [page, setPage] = useState(0) + const TOTAL_PAGES = 4 + + function increasePage() { + if (page < TOTAL_PAGES - 1) { + setPage(page + 1) + } + } + + function decreasePage() { + if (page > 0) { + setPage(page - 1) + } + } + + async function setUserHasSeenWelcome() { + if (user) { + await updateUser(user.id, { ['shouldShowWelcome']: false }) + } + } + + if (!user || !user.shouldShowWelcome) { + return <></> + } else + return ( + <Modal + open={open} + setOpen={(newOpen) => { + setUserHasSeenWelcome() + setOpen(newOpen) + }} + > + <Col className="h-[32rem] place-content-between rounded-md bg-white px-8 py-6 text-sm font-light md:h-[40rem] md:text-lg"> + {page === 0 && <Page0 />} + {page === 1 && <Page1 />} + {page === 2 && <Page2 />} + {page === 3 && <Page3 />} + <Col> + <Row className="place-content-between"> + <ChevronLeftIcon + className={clsx( + 'h-10 w-10 text-gray-400 hover:text-gray-500', + page === 0 ? 'disabled invisible' : '' + )} + onClick={decreasePage} + /> + <PageIndicator page={page} totalpages={TOTAL_PAGES} /> + <ChevronRightIcon + className={clsx( + 'h-10 w-10 text-indigo-500 hover:text-indigo-600', + page === TOTAL_PAGES - 1 ? 'disabled invisible' : '' + )} + onClick={increasePage} + /> + </Row> + <u + className="self-center text-xs text-gray-500" + onClick={() => { + setOpen(false) + setUserHasSeenWelcome() + }} + > + I got the gist, exit welcome + </u> + </Col> + </Col> + </Modal> + ) +} + +function PageIndicator(props: { page: number; totalpages: number }) { + const { page, totalpages } = props + return ( + <Row> + {[...Array(totalpages)].map((e, i) => ( + <div + className={clsx( + 'mx-1.5 my-auto h-1.5 w-1.5 rounded-full', + i === page ? 'bg-indigo-500' : 'bg-gray-300' + )} + /> + ))} + </Row> + ) +} + +function Page0() { + return ( + <> + <img + className="h-2/3 w-2/3 place-self-center object-contain" + src="/welcome/manipurple.png" + /> + <Title className="text-center" text="Welcome to Manifold Markets!" /> + <p> + Manifold Markets is a place where anyone can ask a question about the + future. + </p> + <div className="mt-4">For example,</div> + <div className="mt-2 font-normal text-indigo-700"> + “Will Michelle Obama be the next president of the United States?” + </div> + </> + ) +} + +function Page1() { + return ( + <> + <p> + Your question becomes a prediction market that people can bet{' '} + <span className="font-normal text-indigo-700">mana (M$)</span> on. + </p> + <div className="mt-8 font-semibold">The core idea</div> + <div className="mt-2"> + If people have to put their mana where their mouth is, you’ll get a + pretty accurate answer! + </div> + <video loop autoPlay className="my-4 h-full w-full"> + <source src="/welcome/mana-example.mp4" type="video/mp4" /> + Your browser does not support video + </video> + </> + ) +} + +function Page2() { + return ( + <> + <p> + <span className="mt-4 font-normal text-indigo-700">Mana (M$)</span> is + the play money you bet with. You can also turn it into a real donation + to charity, at a 100:1 ratio. + </p> + <div className="mt-8 font-semibold">Example</div> + <p className="mt-2"> + When you donate <span className="font-semibold">M$1000</span> to + Givewell, Manifold sends them{' '} + <span className="font-semibold">$10 USD</span>. + </p> + <video loop autoPlay className="my-4 h-full w-full"> + <source src="/welcome/charity.mp4" type="video/mp4" /> + Your browser does not support video + </video> + </> + ) +} + +function Page3() { + return ( + <> + <img className="mx-auto object-contain" src="/welcome/treasure.png" /> + <Title className="mx-auto" text="Let's start predicting!" /> + <p className="mb-8"> + As a thank you for signing up, we’ve sent you{' '} + <span className="font-normal text-indigo-700">M$1000 Mana</span>{' '} + </p> + </> + ) +} diff --git a/web/components/pagination.tsx b/web/components/pagination.tsx index 3f4108bc..5f3d4da2 100644 --- a/web/components/pagination.tsx +++ b/web/components/pagination.tsx @@ -1,4 +1,5 @@ import clsx from 'clsx' +import { Spacer } from './layout/spacer' export function Pagination(props: { page: number @@ -23,6 +24,8 @@ export function Pagination(props: { const maxPage = Math.ceil(totalItems / itemsPerPage) - 1 + if (maxPage === 0) return <Spacer h={4} /> + return ( <nav className={clsx( diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index fa50365b..611a19d1 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -10,8 +10,9 @@ import { PortfolioValueGraph } from './portfolio-value-graph' export const PortfolioValueSection = memo( function PortfolioValueSection(props: { portfolioHistory: PortfolioMetrics[] + disableSelector?: boolean }) { - const { portfolioHistory } = props + const { portfolioHistory, disableSelector } = props const lastPortfolioMetrics = last(portfolioHistory) const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime') @@ -30,7 +31,9 @@ export const PortfolioValueSection = memo( <div> <Row className="gap-8"> <div className="mb-4 w-full"> - <Col> + <Col + className={disableSelector ? 'items-center justify-center' : ''} + > <div className="text-sm text-gray-500">Portfolio value</div> <div className="text-lg"> {formatMoney( @@ -40,16 +43,18 @@ export const PortfolioValueSection = memo( </div> </Col> </div> - <select - className="select select-bordered self-start" - onChange={(e) => { - setPortfolioPeriod(e.target.value as Period) - }} - > - <option value="allTime">{allTimeLabel}</option> - <option value="weekly">7 days</option> - <option value="daily">24 hours</option> - </select> + {!disableSelector && ( + <select + className="select select-bordered self-start" + onChange={(e) => { + setPortfolioPeriod(e.target.value as Period) + }} + > + <option value="allTime">{allTimeLabel}</option> + <option value="weekly">7 days</option> + <option value="daily">24 hours</option> + </select> + )} </Row> <PortfolioValueGraph portfolioHistory={portfolioHistory} diff --git a/web/components/qr-code.tsx b/web/components/qr-code.tsx new file mode 100644 index 00000000..a10f8886 --- /dev/null +++ b/web/components/qr-code.tsx @@ -0,0 +1,16 @@ +export function QRCode(props: { + url: string + className?: string + width?: number + height?: number +}) { + const { url, className, width, height } = { + width: 200, + height: 200, + ...props, + } + + const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${width}x${height}&data=${url}` + + return <img src={qrUrl} width={width} height={height} className={className} /> +} diff --git a/web/components/share-market-button.tsx b/web/components/share-market-button.tsx new file mode 100644 index 00000000..ef7b688d --- /dev/null +++ b/web/components/share-market-button.tsx @@ -0,0 +1,18 @@ +import { ENV_CONFIG } from 'common/envs/constants' +import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts' +import { CopyLinkButton } from './copy-link-button' + +export function ShareMarketButton(props: { contract: Contract }) { + const { contract } = props + + const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}` + + return ( + <CopyLinkButton + url={url} + displayUrl={contractUrl(contract)} + buttonClassName="btn-md rounded-l-none" + toastClassName={'-left-28 mt-1'} + /> + ) +} diff --git a/web/components/share-market.tsx b/web/components/share-market.tsx deleted file mode 100644 index be943a34..00000000 --- a/web/components/share-market.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import clsx from 'clsx' - -import { ENV_CONFIG } from 'common/envs/constants' - -import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts' -import { CopyLinkButton } from './copy-link-button' -import { Col } from './layout/col' -import { Row } from './layout/row' - -export function ShareMarket(props: { contract: Contract; className?: string }) { - const { contract, className } = props - - const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}` - - return ( - <Col className={clsx(className, 'gap-3')}> - <div>Share your market</div> - <Row className="mb-6 items-center"> - <CopyLinkButton - url={url} - displayUrl={contractUrl(contract)} - buttonClassName="btn-md rounded-l-none" - toastClassName={'-left-28 mt-1'} - /> - </Row> - </Col> - ) -} diff --git a/web/components/sign-up-prompt.tsx b/web/components/sign-up-prompt.tsx index 0edce22c..8882ccfd 100644 --- a/web/components/sign-up-prompt.tsx +++ b/web/components/sign-up-prompt.tsx @@ -2,16 +2,20 @@ import React from 'react' import { useUser } from 'web/hooks/use-user' import { firebaseLogin } from 'web/lib/firebase/users' import { withTracking } from 'web/lib/service/analytics' +import { Button } from './button' -export function SignUpPrompt() { +export function SignUpPrompt(props: { label?: string; className?: string }) { + const { label, className } = props const user = useUser() return user === null ? ( - <button - className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-indigo-500 to-blue-500 px-10 text-lg font-medium normal-case hover:from-indigo-600 hover:to-blue-600" + <Button onClick={withTracking(firebaseLogin, 'sign up to bet')} + className={className} + size="lg" + color="gradient" > - Sign up to bet! - </button> + {label ?? 'Sign up to bet!'} + </Button> ) : null } diff --git a/web/components/title.tsx b/web/components/title.tsx index e58aee39..e0a0be61 100644 --- a/web/components/title.tsx +++ b/web/components/title.tsx @@ -5,7 +5,7 @@ export function Title(props: { text: string; className?: string }) { return ( <h1 className={clsx( - 'my-4 inline-block text-2xl text-indigo-700 sm:my-6 sm:text-3xl', + 'my-4 inline-block text-2xl font-normal text-indigo-700 sm:my-6 sm:text-3xl', className )} > diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index d628e92d..fb349149 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -12,7 +12,7 @@ import { unfollow, User, } from 'web/lib/firebase/users' -import { CreatorContractsList } from './contract/contracts-list' +import { CreatorContractsList } from './contract/contracts-grid' import { SEO } from './SEO' import { Page } from './page' import { SiteLink } from './site-link' diff --git a/web/hooks/use-bets.ts b/web/hooks/use-bets.ts index 68b296cd..9155d25e 100644 --- a/web/hooks/use-bets.ts +++ b/web/hooks/use-bets.ts @@ -9,12 +9,27 @@ import { } from 'web/lib/firebase/bets' import { LimitBet } from 'common/bet' -export const useBets = (contractId: string) => { +export const useBets = ( + contractId: string, + options?: { filterChallenges: boolean; filterRedemptions: boolean } +) => { const [bets, setBets] = useState<Bet[] | undefined>() - + const filterChallenges = !!options?.filterChallenges + const filterRedemptions = !!options?.filterRedemptions useEffect(() => { - if (contractId) return listenForBets(contractId, setBets) - }, [contractId]) + if (contractId) + return listenForBets(contractId, (bets) => { + if (filterChallenges || filterRedemptions) + setBets( + bets.filter( + (bet) => + (filterChallenges ? !bet.challengeSlug : true) && + (filterRedemptions ? !bet.isRedemption : true) + ) + ) + else setBets(bets) + }) + }, [contractId, filterChallenges, filterRedemptions]) return bets } diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index 84913962..aeeaf2ab 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -5,6 +5,7 @@ import { listenForGroup, listenForGroups, listenForMemberGroups, + listenForOpenGroups, listGroups, } from 'web/lib/firebase/groups' import { getUser, getUsers } from 'web/lib/firebase/users' @@ -32,6 +33,16 @@ export const useGroups = () => { return groups } +export const useOpenGroups = () => { + const [groups, setGroups] = useState<Group[]>([]) + + useEffect(() => { + return listenForOpenGroups(setGroups) + }, []) + + return groups +} + export const useMemberGroups = ( userId: string | null | undefined, options?: { withChatEnabled: boolean }, diff --git a/web/hooks/use-save-referral.ts b/web/hooks/use-save-referral.ts index 788268b0..cc96ec72 100644 --- a/web/hooks/use-save-referral.ts +++ b/web/hooks/use-save-referral.ts @@ -6,7 +6,7 @@ import { User, writeReferralInfo } from 'web/lib/firebase/users' export const useSaveReferral = ( user?: User | null, options?: { - defaultReferrer?: string + defaultReferrerUsername?: string contractId?: string groupId?: string } @@ -18,10 +18,14 @@ export const useSaveReferral = ( referrer?: string } - const actualReferrer = referrer || options?.defaultReferrer + const referrerOrDefault = referrer || options?.defaultReferrerUsername - if (!user && router.isReady && actualReferrer) { - writeReferralInfo(actualReferrer, options?.contractId, options?.groupId) + if (!user && router.isReady && referrerOrDefault) { + writeReferralInfo(referrerOrDefault, { + contractId: options?.contractId, + overwriteReferralUsername: referrer, + groupId: options?.groupId, + }) } }, [user, router, options]) } diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index 9023dc1a..c4bce0c0 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -1,8 +1,6 @@ -import { defaults, debounce } from 'lodash' +import { debounce } from 'lodash' import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' -import { useSearchBox } from 'react-instantsearch-hooks-web' -import { track } from 'web/lib/service/analytics' import { DEFAULT_SORT } from 'web/components/contract-search' const MARKETS_SORT = 'markets_sort' @@ -27,98 +25,71 @@ export function getSavedSort() { } } -export function useInitialQueryAndSort(options?: { - defaultSort: Sort +export function useQueryAndSortParams(options?: { + defaultSort?: Sort shouldLoadFromStorage?: boolean }) { - const { defaultSort, shouldLoadFromStorage } = defaults(options, { - defaultSort: DEFAULT_SORT, - shouldLoadFromStorage: true, - }) + const { defaultSort = DEFAULT_SORT, shouldLoadFromStorage = true } = + options ?? {} const router = useRouter() - const [initialSort, setInitialSort] = useState<Sort | undefined>(undefined) - const [initialQuery, setInitialQuery] = useState('') - - useEffect(() => { - // If there's no sort option, then set the one from localstorage - if (router.isReady) { - const { s: sort, q: query } = router.query as { - q?: string - s?: Sort - } - - setInitialQuery(query ?? '') - - if (!sort && shouldLoadFromStorage) { - console.log('ready loading from storage ', sort ?? defaultSort) - const localSort = getSavedSort() - if (localSort) { - // Use replace to not break navigating back. - router.replace( - { query: { ...router.query, s: localSort } }, - undefined, - { shallow: true } - ) - } - setInitialSort(localSort ?? defaultSort) - } else { - setInitialSort(sort ?? defaultSort) - } - } - }, [defaultSort, router.isReady, shouldLoadFromStorage]) - - return { - initialSort, - initialQuery, + const { s: sort, q: query } = router.query as { + q?: string + s?: Sort } -} - -export function useUpdateQueryAndSort(props: { - shouldLoadFromStorage: boolean -}) { - const { shouldLoadFromStorage } = props - const router = useRouter() const setSort = (sort: Sort | undefined) => { - if (sort !== router.query.s) { - router.query.s = sort - router.replace({ query: { ...router.query, s: sort } }, undefined, { - shallow: true, - }) - if (shouldLoadFromStorage) { - localStorage.setItem(MARKETS_SORT, sort || '') - } + router.replace({ query: { ...router.query, s: sort } }, undefined, { + shallow: true, + }) + if (shouldLoadFromStorage) { + localStorage.setItem(MARKETS_SORT, sort || '') } } - const { query, refine } = useSearchBox() + const [queryState, setQueryState] = useState(query) + + useEffect(() => { + setQueryState(query) + }, [query]) // Debounce router query update. const pushQuery = useMemo( () => debounce((query: string | undefined) => { - if (query) { - router.query.q = query - } else { - delete router.query.q - } - router.replace({ query: router.query }, undefined, { + const queryObj = { ...router.query, q: query } + if (!query) delete queryObj.q + router.replace({ query: queryObj }, undefined, { shallow: true, }) - track('search', { query }) - }, 500), + }, 100), [router] ) const setQuery = (query: string | undefined) => { - refine(query ?? '') + setQueryState(query) pushQuery(query) } + useEffect(() => { + // If there's no sort option, then set the one from localstorage + if (router.isReady && !sort && shouldLoadFromStorage) { + const localSort = localStorage.getItem(MARKETS_SORT) as Sort + if (localSort && localSort !== defaultSort) { + // Use replace to not break navigating back. + router.replace( + { query: { ...router.query, s: localSort } }, + undefined, + { shallow: true } + ) + } + } + }) + return { + sort: sort ?? defaultSort, + query: queryState ?? '', setSort, setQuery, - query, } } diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index 4c492d6c..d84c7d03 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -2,7 +2,7 @@ import { useContext, useEffect, useState } from 'react' import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import { QueryClient } from 'react-query' -import { doc, DocumentData } from 'firebase/firestore' +import { doc, DocumentData, where } from 'firebase/firestore' import { PrivateUser } from 'common/user' import { getUser, diff --git a/web/lib/firebase/api.ts b/web/lib/firebase/api.ts index 27d6caa3..5f250ce7 100644 --- a/web/lib/firebase/api.ts +++ b/web/lib/firebase/api.ts @@ -80,3 +80,11 @@ export function claimManalink(params: any) { export function createGroup(params: any) { return call(getFunctionUrl('creategroup'), 'POST', params) } + +export function acceptChallenge(params: any) { + return call(getFunctionUrl('acceptchallenge'), 'POST', params) +} + +export function getCurrentUser(params: any) { + return call(getFunctionUrl('getcurrentuser'), 'GET', params) +} diff --git a/web/lib/firebase/auth.ts b/web/lib/firebase/auth.ts index b6daea6e..5363aa08 100644 --- a/web/lib/firebase/auth.ts +++ b/web/lib/firebase/auth.ts @@ -2,53 +2,73 @@ import { PROJECT_ID } from 'common/envs/constants' import { setCookie, getCookies } from '../util/cookie' import { IncomingMessage, ServerResponse } from 'http' -const TOKEN_KINDS = ['refresh', 'id'] as const -type TokenKind = typeof TOKEN_KINDS[number] +const ONE_HOUR_SECS = 60 * 60 +const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10 +const TOKEN_KINDS = ['refresh', 'id', 'custom'] as const +const TOKEN_AGES = { + id: ONE_HOUR_SECS, + refresh: TEN_YEARS_SECS, + custom: ONE_HOUR_SECS, +} as const +export type TokenKind = typeof TOKEN_KINDS[number] const getAuthCookieName = (kind: TokenKind) => { const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replace(/-/g, '_') return `FIREBASE_TOKEN_${suffix}` } -const ID_COOKIE_NAME = getAuthCookieName('id') -const REFRESH_COOKIE_NAME = getAuthCookieName('refresh') +const COOKIE_NAMES = Object.fromEntries( + TOKEN_KINDS.map((k) => [k, getAuthCookieName(k)]) +) as Record<TokenKind, string> -export const getAuthCookies = (request?: IncomingMessage) => { - const data = request != null ? request.headers.cookie ?? '' : document.cookie - const cookies = getCookies(data) - return { - idToken: cookies[ID_COOKIE_NAME] as string | undefined, - refreshToken: cookies[REFRESH_COOKIE_NAME] as string | undefined, - } -} - -export const setAuthCookies = ( - idToken?: string, - refreshToken?: string, - response?: ServerResponse -) => { - // these tokens last an hour - const idMaxAge = idToken != null ? 60 * 60 : 0 - const idCookie = setCookie(ID_COOKIE_NAME, idToken ?? '', [ - ['path', '/'], - ['max-age', idMaxAge.toString()], - ['samesite', 'lax'], - ['secure'], - ]) - // these tokens don't expire - const refreshMaxAge = refreshToken != null ? 60 * 60 * 24 * 365 * 10 : 0 - const refreshCookie = setCookie(REFRESH_COOKIE_NAME, refreshToken ?? '', [ - ['path', '/'], - ['max-age', refreshMaxAge.toString()], - ['samesite', 'lax'], - ['secure'], - ]) - if (response != null) { - response.setHeader('Set-Cookie', [idCookie, refreshCookie]) +const getCookieDataIsomorphic = (req?: IncomingMessage) => { + if (req != null) { + return req.headers.cookie ?? '' + } else if (document != null) { + return document.cookie } else { - document.cookie = idCookie - document.cookie = refreshCookie + throw new Error( + 'Neither request nor document is available; no way to get cookies.' + ) } } -export const deleteAuthCookies = () => setAuthCookies() +const setCookieDataIsomorphic = (cookies: string[], res?: ServerResponse) => { + if (res != null) { + res.setHeader('Set-Cookie', cookies) + } else if (document != null) { + for (const ck of cookies) { + document.cookie = ck + } + } else { + throw new Error( + 'Neither response nor document is available; no way to set cookies.' + ) + } +} + +export const getTokensFromCookies = (req?: IncomingMessage) => { + const cookies = getCookies(getCookieDataIsomorphic(req)) + return Object.fromEntries( + TOKEN_KINDS.map((k) => [k, cookies[COOKIE_NAMES[k]]]) + ) as Partial<Record<TokenKind, string>> +} + +export const setTokenCookies = ( + cookies: Partial<Record<TokenKind, string | undefined>>, + res?: ServerResponse +) => { + const data = TOKEN_KINDS.filter((k) => k in cookies).map((k) => { + const maxAge = cookies[k] ? TOKEN_AGES[k as TokenKind] : 0 + return setCookie(COOKIE_NAMES[k], cookies[k] ?? '', [ + ['path', '/'], + ['max-age', maxAge.toString()], + ['samesite', 'lax'], + ['secure'], + ]) + }) + setCookieDataIsomorphic(data, res) +} + +export const deleteTokenCookies = (res?: ServerResponse) => + setTokenCookies({ id: undefined, refresh: undefined, custom: undefined }, res) diff --git a/web/lib/firebase/challenges.ts b/web/lib/firebase/challenges.ts new file mode 100644 index 00000000..89da7f80 --- /dev/null +++ b/web/lib/firebase/challenges.ts @@ -0,0 +1,150 @@ +import { + collectionGroup, + doc, + getDoc, + orderBy, + query, + setDoc, + where, +} from 'firebase/firestore' +import { Challenge } from 'common/challenge' +import { customAlphabet } from 'nanoid' +import { coll, listenForValue, listenForValues } from './utils' +import { useEffect, useState } from 'react' +import { User } from 'common/user' +import { db } from './init' +import { Contract } from 'common/contract' +import { ENV_CONFIG } from 'common/envs/constants' + +export const challenges = (contractId: string) => + coll<Challenge>(`contracts/${contractId}/challenges`) + +export function getChallengeUrl(challenge: Challenge) { + return `https://${ENV_CONFIG.domain}/challenges/${challenge.creatorUsername}/${challenge.contractSlug}/${challenge.slug}` +} +export async function createChallenge(data: { + creator: User + outcome: 'YES' | 'NO' | number + contract: Contract + creatorAmount: number + acceptorAmount: number + expiresTime: number | null + message: string +}) { + const { + creator, + creatorAmount, + expiresTime, + message, + contract, + outcome, + acceptorAmount, + } = data + + // At 100 IDs per hour, using this alphabet and 8 chars, there's a 1% chance of collision in 2 years + // See https://zelark.github.io/nano-id-cc/ + const nanoid = customAlphabet( + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', + 8 + ) + const slug = nanoid() + + if (creatorAmount <= 0 || isNaN(creatorAmount) || !isFinite(creatorAmount)) + return null + + const challenge: Challenge = { + slug, + creatorId: creator.id, + creatorUsername: creator.username, + creatorName: creator.name, + creatorAvatarUrl: creator.avatarUrl, + creatorAmount, + creatorOutcome: outcome.toString(), + creatorOutcomeProb: creatorAmount / (creatorAmount + acceptorAmount), + acceptorOutcome: outcome === 'YES' ? 'NO' : 'YES', + acceptorAmount, + contractSlug: contract.slug, + contractId: contract.id, + contractQuestion: contract.question, + contractCreatorUsername: contract.creatorUsername, + createdTime: Date.now(), + expiresTime, + maxUses: 1, + acceptedByUserIds: [], + acceptances: [], + isResolved: false, + message, + } + + await setDoc(doc(challenges(contract.id), slug), challenge) + return challenge +} + +// TODO: This required an index, make sure to also set up in prod +function listUserChallenges(fromId?: string) { + return query( + collectionGroup(db, 'challenges'), + where('creatorId', '==', fromId), + orderBy('createdTime', 'desc') + ) +} + +function listChallenges() { + return query(collectionGroup(db, 'challenges')) +} + +export const useAcceptedChallenges = () => { + const [links, setLinks] = useState<Challenge[]>([]) + + useEffect(() => { + listenForValues(listChallenges(), (challenges: Challenge[]) => { + setLinks( + challenges + .sort((a: Challenge, b: Challenge) => b.createdTime - a.createdTime) + .filter((challenge) => challenge.acceptedByUserIds.length > 0) + ) + }) + }, []) + + return links +} + +export function listenForChallenge( + slug: string, + contractId: string, + setLinks: (challenge: Challenge | null) => void +) { + return listenForValue<Challenge>(doc(challenges(contractId), slug), setLinks) +} + +export function useChallenge(slug: string, contractId: string | undefined) { + const [challenge, setChallenge] = useState<Challenge | null>() + useEffect(() => { + if (slug && contractId) { + listenForChallenge(slug, contractId, setChallenge) + } + }, [contractId, slug]) + return challenge +} + +export function listenForUserChallenges( + fromId: string | undefined, + setLinks: (links: Challenge[]) => void +) { + return listenForValues<Challenge>(listUserChallenges(fromId), setLinks) +} + +export const useUserChallenges = (fromId?: string) => { + const [links, setLinks] = useState<Challenge[]>([]) + + useEffect(() => { + if (fromId) return listenForUserChallenges(fromId, setLinks) + }, [fromId]) + + return links +} + +export const getChallenge = async (slug: string, contractId: string) => { + const challenge = await getDoc(doc(challenges(contractId), slug)) + return challenge.data() as Challenge +} diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index 3093f764..e82c6d45 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -14,6 +14,7 @@ import { User } from 'common/user' import { Comment } from 'common/comment' import { removeUndefinedProps } from 'common/util/object' import { track } from '@amplitude/analytics-browser' +import { JSONContent } from '@tiptap/react' export type { Comment } @@ -21,7 +22,7 @@ export const MAX_COMMENT_LENGTH = 10000 export async function createCommentOnContract( contractId: string, - text: string, + content: JSONContent, commenter: User, betId?: string, answerOutcome?: string, @@ -34,7 +35,7 @@ export async function createCommentOnContract( id: ref.id, contractId, userId: commenter.id, - text: text.slice(0, MAX_COMMENT_LENGTH), + content: content, createdTime: Date.now(), userName: commenter.name, userUsername: commenter.username, @@ -53,7 +54,7 @@ export async function createCommentOnContract( } export async function createCommentOnGroup( groupId: string, - text: string, + content: JSONContent, user: User, replyToCommentId?: string ) { @@ -62,7 +63,7 @@ export async function createCommentOnGroup( id: ref.id, groupId, userId: user.id, - text: text.slice(0, MAX_COMMENT_LENGTH), + content: content, createdTime: Date.now(), userName: user.name, userUsername: user.username, @@ -81,6 +82,7 @@ export async function createCommentOnGroup( function getCommentsCollection(contractId: string) { return collection(db, 'contracts', contractId, 'comments') } + function getCommentsOnGroupCollection(groupId: string) { return collection(db, 'groups', groupId, 'comments') } @@ -91,6 +93,14 @@ export async function listAllComments(contractId: string) { return comments } +export async function listAllCommentsOnGroup(groupId: string) { + const comments = await getValues<Comment>( + getCommentsOnGroupCollection(groupId) + ) + comments.sort((c1, c2) => c1.createdTime - c2.createdTime) + return comments +} + export function listenForCommentsOnContract( contractId: string, setComments: (comments: Comment[]) => void diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 9e5de871..3a751c18 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -35,6 +35,13 @@ export function contractPath(contract: Contract) { return `/${contract.creatorUsername}/${contract.slug}` } +export function contractPathWithoutContract( + creatorUsername: string, + slug: string +) { + return `/${creatorUsername}/${slug}` +} + export function homeContractPath(contract: Contract) { return `/home?c=${contract.slug}` } diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index debc9a97..3f5d18af 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -8,7 +8,6 @@ import { } from 'firebase/firestore' import { sortBy, uniq } from 'lodash' import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group' -import { updateContract } from './contracts' import { coll, getValue, @@ -17,6 +16,7 @@ import { listenForValues, } from './utils' import { Contract } from 'common/contract' +import { updateContract } from 'web/lib/firebase/contracts' export const groups = coll<Group>('groups') @@ -52,6 +52,13 @@ export function listenForGroups(setGroups: (groups: Group[]) => void) { return listenForValues(groups, setGroups) } +export function listenForOpenGroups(setGroups: (groups: Group[]) => void) { + return listenForValues( + query(groups, where('anyoneCanJoin', '==', true)), + setGroups + ) +} + export function getGroup(groupId: string) { return getValue<Group>(doc(groups, groupId)) } @@ -129,23 +136,23 @@ export async function addContractToGroup( contract: Contract, userId: string ) { - if (!contract.groupLinks?.map((l) => l.groupId).includes(group.id)) { - const newGroupLinks = [ - ...(contract.groupLinks ?? []), - { - groupId: group.id, - createdTime: Date.now(), - slug: group.slug, - userId, - name: group.name, - } as GroupLink, - ] + if (!canModifyGroupContracts(group, userId)) return + const newGroupLinks = [ + ...(contract.groupLinks ?? []), + { + groupId: group.id, + createdTime: Date.now(), + slug: group.slug, + userId, + name: group.name, + } as GroupLink, + ] + // It's good to update the contract first, so the on-update-group trigger doesn't re-add them + await updateContract(contract.id, { + groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]), + groupLinks: newGroupLinks, + }) - await updateContract(contract.id, { - groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]), - groupLinks: newGroupLinks, - }) - } if (!group.contractIds.includes(contract.id)) { return await updateGroup(group, { contractIds: uniq([...group.contractIds, contract.id]), @@ -160,8 +167,11 @@ export async function addContractToGroup( export async function removeContractFromGroup( group: Group, - contract: Contract + contract: Contract, + userId: string ) { + if (!canModifyGroupContracts(group, userId)) return + if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) { const newGroupLinks = contract.groupLinks?.filter( (link) => link.slug !== group.slug @@ -186,29 +196,10 @@ export async function removeContractFromGroup( } } -export async function setContractGroupLinks( - group: Group, - contractId: string, - userId: string -) { - await updateContract(contractId, { - groupSlugs: [group.slug], - groupLinks: [ - { - groupId: group.id, - name: group.name, - slug: group.slug, - userId, - createdTime: Date.now(), - } as GroupLink, - ], - }) - return await updateGroup(group, { - contractIds: uniq([...group.contractIds, contractId]), - }) - .then(() => group) - .catch((err) => { - console.error('error adding contract to group', err) - return err - }) +export function canModifyGroupContracts(group: Group, userId: string) { + return ( + group.creatorId === userId || + group.memberIds.includes(userId) || + group.anyoneCanJoin + ) } diff --git a/web/lib/firebase/server-auth.ts b/web/lib/firebase/server-auth.ts index 47eadb45..b0d225f1 100644 --- a/web/lib/firebase/server-auth.ts +++ b/web/lib/firebase/server-auth.ts @@ -1,9 +1,25 @@ -import * as admin from 'firebase-admin' import fetch from 'node-fetch' import { IncomingMessage, ServerResponse } from 'http' import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants' -import { getAuthCookies, setAuthCookies } from './auth' -import { GetServerSideProps, GetServerSidePropsContext } from 'next' +import { getFunctionUrl } from 'common/api' +import { UserCredential } from 'firebase/auth' +import { + getTokensFromCookies, + setTokenCookies, + deleteTokenCookies, +} from './auth' +import { + GetServerSideProps, + GetServerSidePropsContext, + GetServerSidePropsResult, +} from 'next' + +// server firebase SDK +import * as admin from 'firebase-admin' + +// client firebase SDK +import { app as clientApp } from './init' +import { getAuth, signInWithCustomToken } from 'firebase/auth' const ensureApp = async () => { // Note: firebase-admin can only be imported from a server context, @@ -33,7 +49,21 @@ const requestFirebaseIdToken = async (refreshToken: string) => { if (!result.ok) { throw new Error(`Could not refresh ID token: ${await result.text()}`) } - return (await result.json()) as any + return (await result.json()) as { id_token: string; refresh_token: string } +} + +const requestManifoldCustomToken = async (idToken: string) => { + const functionUrl = getFunctionUrl('getcustomtoken') + const result = await fetch(functionUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${idToken}`, + }, + }) + if (!result.ok) { + throw new Error(`Could not get custom token: ${await result.text()}`) + } + return (await result.json()) as { token: string } } type RequestContext = { @@ -41,39 +71,103 @@ type RequestContext = { res: ServerResponse } -export const getServerAuthenticatedUid = async (ctx: RequestContext) => { - const app = await ensureApp() - const auth = app.auth() - const { idToken, refreshToken } = getAuthCookies(ctx.req) +const authAndRefreshTokens = async (ctx: RequestContext) => { + const adminAuth = (await ensureApp()).auth() + const clientAuth = getAuth(clientApp) + let { id, refresh, custom } = getTokensFromCookies(ctx.req) - // If we have a valid ID token, verify the user immediately with no network trips. - // If the ID token doesn't verify, we'll have to refresh it to see who they are. - // If they don't have any tokens, then we have no idea who they are. - if (idToken != null) { + // step 0: if you have no refresh token you are logged out + if (refresh == null) { + return undefined + } + + // step 1: given a valid refresh token, ensure a valid ID token + if (id != null) { + // if they have an ID token, throw it out if it's invalid/expired try { - return (await auth.verifyIdToken(idToken))?.uid + await adminAuth.verifyIdToken(id) } catch { - // plausibly expired; try the refresh token, if it's present + id = undefined } } - if (refreshToken != null) { + if (id == null) { + // ask for a new one from google using the refresh token try { - const resp = await requestFirebaseIdToken(refreshToken) - setAuthCookies(resp.id_token, resp.refresh_token, ctx.res) - return (await auth.verifyIdToken(resp.id_token))?.uid + const resp = await requestFirebaseIdToken(refresh) + id = resp.id_token + refresh = resp.refresh_token } catch (e) { - // this is a big unexpected problem -- either their cookies are corrupt - // or the refresh token API is down. functionally, they are not logged in + // big unexpected problem -- functionally, they are not logged in console.error(e) + return undefined + } + } + + // step 2: given a valid ID token, ensure a valid custom token, and sign in + // to the client SDK with the custom token + if (custom != null) { + // sign in with this token, or throw it out if it's invalid/expired + try { + return { + creds: await signInWithCustomToken(clientAuth, custom), + id, + refresh, + custom, + } + } catch { + custom = undefined + } + } + if (custom == null) { + // ask for a new one from our cloud functions using the ID token, then sign in + try { + const resp = await requestManifoldCustomToken(id) + custom = resp.token + return { + creds: await signInWithCustomToken(clientAuth, custom), + id, + refresh, + custom, + } + } catch (e) { + // big unexpected problem -- functionally, they are not logged in + console.error(e) + return undefined } } - return undefined } -export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => { +export const authenticateOnServer = async (ctx: RequestContext) => { + const tokens = await authAndRefreshTokens(ctx) + const creds = tokens?.creds + try { + if (tokens == null) { + deleteTokenCookies(ctx.res) + } else { + setTokenCookies(tokens, ctx.res) + } + } catch (e) { + // definitely not supposed to happen, but let's be maximally robust + console.error(e) + } + return creds +} + +// note that we might want to define these types more generically if we want better +// type safety on next.js stuff... see the definition of GetServerSideProps + +type GetServerSidePropsAuthed<P> = ( + context: GetServerSidePropsContext, + creds: UserCredential +) => Promise<GetServerSidePropsResult<P>> + +export const redirectIfLoggedIn = <P>( + dest: string, + fn?: GetServerSideProps<P> +) => { return async (ctx: GetServerSidePropsContext) => { - const uid = await getServerAuthenticatedUid(ctx) - if (uid == null) { + const creds = await authenticateOnServer(ctx) + if (creds == null) { return fn != null ? await fn(ctx) : { props: {} } } else { return { redirect: { destination: dest, permanent: false } } @@ -81,13 +175,16 @@ export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => { } } -export const redirectIfLoggedOut = (dest: string, fn?: GetServerSideProps) => { +export const redirectIfLoggedOut = <P>( + dest: string, + fn?: GetServerSidePropsAuthed<P> +) => { return async (ctx: GetServerSidePropsContext) => { - const uid = await getServerAuthenticatedUid(ctx) - if (uid == null) { + const creds = await authenticateOnServer(ctx) + if (creds == null) { return { redirect: { destination: dest, permanent: false } } } else { - return fn != null ? await fn(ctx) : { props: {} } + return fn != null ? await fn(ctx, creds) : { props: {} } } } } diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 4f618586..3096f00f 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -52,6 +52,11 @@ export async function getUser(userId: string) { return (await getDoc(doc(users, userId))).data()! } +export async function getPrivateUser(userId: string) { + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + return (await getDoc(doc(users, userId))).data()! +} + export async function getUserByUsername(username: string) { // Find a user whose username matches the given username, or null if no such user exists. const q = query(users, where('username', '==', username), limit(1)) @@ -96,22 +101,25 @@ const CACHED_REFERRAL_GROUP_ID_KEY = 'CACHED_REFERRAL_GROUP_KEY' export function writeReferralInfo( defaultReferrerUsername: string, - contractId?: string, - referralUsername?: string, - groupId?: string + otherOptions?: { + contractId?: string + overwriteReferralUsername?: string + groupId?: string + } ) { const local = safeLocalStorage() const cachedReferralUser = local?.getItem(CACHED_REFERRAL_USERNAME_KEY) + const { contractId, overwriteReferralUsername, groupId } = otherOptions || {} // Write the first referral username we see. if (!cachedReferralUser) local?.setItem( CACHED_REFERRAL_USERNAME_KEY, - referralUsername || defaultReferrerUsername + overwriteReferralUsername || defaultReferrerUsername ) // If an explicit referral query is passed, overwrite the cached referral username. - if (referralUsername) - local?.setItem(CACHED_REFERRAL_USERNAME_KEY, referralUsername) + if (overwriteReferralUsername) + local?.setItem(CACHED_REFERRAL_USERNAME_KEY, overwriteReferralUsername) // Always write the most recent explicit group invite query value if (groupId) local?.setItem(CACHED_REFERRAL_GROUP_ID_KEY, groupId) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 58e7c2e8..5866f899 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -1,18 +1,18 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { ArrowLeftIcon } from '@heroicons/react/outline' +import { groupBy, keyBy, mapValues, sortBy, sumBy } from 'lodash' import { useContractWithPreload } from 'web/hooks/use-contract' import { ContractOverview } from 'web/components/contract/contract-overview' import { BetPanel } from 'web/components/bet-panel' import { Col } from 'web/components/layout/col' -import { useUser } from 'web/hooks/use-user' +import { useUser, useUserById } from 'web/hooks/use-user' import { ResolutionPanel } from 'web/components/resolution-panel' import { Spacer } from 'web/components/layout/spacer' import { Contract, getContractFromSlug, tradingAllowed, - getBinaryProbPercent, } from 'web/lib/firebase/contracts' import { SEO } from 'web/components/SEO' import { Page } from 'web/components/page' @@ -21,26 +21,29 @@ import { Comment, listAllComments } from 'web/lib/firebase/comments' import Custom404 from '../404' import { AnswersPanel } from 'web/components/answers/answers-panel' import { fromPropz, usePropz } from 'web/hooks/use-propz' +import { Leaderboard } from 'web/components/leaderboard' +import { resolvedPayout } from 'common/calculate' +import { formatMoney } from 'common/util/format' import { ContractTabs } from 'web/components/contract/contract-tabs' -import { contractTextDetails } from 'web/components/contract/contract-details' import { useWindowSize } from 'web/hooks/use-window-size' import Confetti from 'react-confetti' -import { NumericBetPanel } from '../../components/numeric-bet-panel' -import { NumericResolutionPanel } from '../../components/numeric-resolution-panel' +import { NumericBetPanel } from 'web/components/numeric-bet-panel' +import { NumericResolutionPanel } from 'web/components/numeric-resolution-panel' import { useIsIframe } from 'web/hooks/use-is-iframe' import ContractEmbedPage from '../embed/[username]/[contractSlug]' import { useBets } from 'web/hooks/use-bets' import { CPMMBinaryContract } from 'common/contract' import { AlertBox } from 'web/components/alert-box' import { useTracking } from 'web/hooks/use-tracking' -import { useTipTxns } from 'web/hooks/use-tip-txns' +import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' import { useLiquidity } from 'web/hooks/use-liquidity' -import { richTextToString } from 'common/util/parse' import { useSaveReferral } from 'web/hooks/use-save-referral' -import { - ContractLeaderboard, - ContractTopTrades, -} from 'web/components/contract/contract-leaderboard' +import { getOpenGraphProps } from 'web/components/contract/contract-card-preview' +import { User } from 'common/user' +import { listUsers } from 'web/lib/firebase/users' +import { FeedComment } from 'web/components/feed/feed-comments' +import { Title } from 'web/components/title' +import { FeedBet } from 'web/components/feed/feed-bets' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -153,7 +156,7 @@ export function ContractPageContent( const ogCardProps = getOpenGraphProps(contract) useSaveReferral(user, { - defaultReferrer: contract.creatorUsername, + defaultReferrerUsername: contract.creatorUsername, contractId: contract.id, }) @@ -208,7 +211,10 @@ export function ContractPageContent( </button> )} - <ContractOverview contract={contract} bets={bets} /> + <ContractOverview + contract={contract} + bets={bets.filter((b) => !b.challengeSlug)} + /> {isNumeric && ( <AlertBox @@ -258,34 +264,124 @@ export function ContractPageContent( ) } -const getOpenGraphProps = (contract: Contract) => { - const { - resolution, - question, - creatorName, - creatorUsername, - outcomeType, - creatorAvatarUrl, - description: desc, - } = contract - const probPercent = - outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined +function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) { + const { contract, bets } = props + const [users, setUsers] = useState<User[]>() - const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc) + const { userProfits, top5Ids } = useMemo(() => { + // Create a map of userIds to total profits (including sales) + const openBets = bets.filter((bet) => !bet.isSold && !bet.sale) + const betsByUser = groupBy(openBets, 'userId') - const description = resolution - ? `Resolved ${resolution}. ${stringDesc}` - : probPercent - ? `${probPercent} chance. ${stringDesc}` - : stringDesc + const userProfits = mapValues(betsByUser, (bets) => + sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount) + ) + // Find the 5 users with the most profits + const top5Ids = Object.entries(userProfits) + .sort(([_i1, p1], [_i2, p2]) => p2 - p1) + .filter(([, p]) => p > 0) + .slice(0, 5) + .map(([id]) => id) + return { userProfits, top5Ids } + }, [contract, bets]) - return { - question, - probability: probPercent, - metadata: contractTextDetails(contract), - creatorName, - creatorUsername, - creatorAvatarUrl, - description, - } + useEffect(() => { + if (top5Ids.length > 0) { + listUsers(top5Ids).then((users) => { + const sortedUsers = sortBy(users, (user) => -userProfits[user.id]) + setUsers(sortedUsers) + }) + } + }, [userProfits, top5Ids]) + + return users && users.length > 0 ? ( + <Leaderboard + title="🏅 Top bettors" + users={users || []} + columns={[ + { + header: 'Total profit', + renderCell: (user) => formatMoney(userProfits[user.id] || 0), + }, + ]} + className="mt-12 max-w-sm" + /> + ) : null +} + +function ContractTopTrades(props: { + contract: Contract + bets: Bet[] + comments: Comment[] + tips: CommentTipMap +}) { + const { contract, bets, comments, tips } = props + const commentsById = keyBy(comments, 'id') + const betsById = keyBy(bets, 'id') + + // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit + // Otherwise, we record the profit at resolution time + const profitById: Record<string, number> = {} + for (const bet of bets) { + if (bet.sale) { + const originalBet = betsById[bet.sale.betId] + const profit = bet.sale.amount - originalBet.amount + profitById[bet.id] = profit + profitById[originalBet.id] = profit + } else { + profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount + } + } + + // Now find the betId with the highest profit + const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id + const topBettor = useUserById(betsById[topBetId]?.userId) + + // And also the commentId of the comment with the highest profit + const topCommentId = sortBy( + comments, + (c) => c.betId && -profitById[c.betId] + )[0]?.id + + return ( + <div className="mt-12 max-w-sm"> + {topCommentId && profitById[topCommentId] > 0 && ( + <> + <Title text="💬 Proven correct" className="!mt-0" /> + <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> + <FeedComment + contract={contract} + comment={commentsById[topCommentId]} + tips={tips[topCommentId]} + betsBySameUser={[betsById[topCommentId]]} + smallAvatar={false} + /> + </div> + <div className="mt-2 text-sm text-gray-500"> + {commentsById[topCommentId].userName} made{' '} + {formatMoney(profitById[topCommentId] || 0)}! + </div> + <Spacer h={16} /> + </> + )} + + {/* If they're the same, only show the comment; otherwise show both */} + {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && ( + <> + <Title text="💸 Smartest money" className="!mt-0" /> + <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> + <FeedBet + contract={contract} + bet={betsById[topBetId]} + hideOutcome={false} + smallAvatar={false} + /> + </div> + <div className="mt-2 text-sm text-gray-500"> + {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! + </div> + </> + )} + </div> + ) } diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index 52316eb0..42b5e922 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -6,6 +6,7 @@ import Script from 'next/script' import { usePreserveScroll } from 'web/hooks/use-preserve-scroll' import { QueryClient, QueryClientProvider } from 'react-query' import { AuthProvider } from 'web/components/auth-context' +import Welcome from 'web/components/onboarding/welcome' function firstLine(msg: string) { return msg.replace(/\r?\n.*/s, '') @@ -78,9 +79,9 @@ function MyApp({ Component, pageProps }: AppProps) { content="width=device-width, initial-scale=1, maximum-scale=1" /> </Head> - - <AuthProvider> + <AuthProvider serverUser={pageProps.user}> <QueryClientProvider client={queryClient}> + <Welcome {...pageProps} /> <Component {...pageProps} /> </QueryClientProvider> </AuthProvider> diff --git a/web/pages/api/v0/group/[slug].ts b/web/pages/api/v0/group/[slug].ts new file mode 100644 index 00000000..f9271591 --- /dev/null +++ b/web/pages/api/v0/group/[slug].ts @@ -0,0 +1,18 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { getGroupBySlug } from 'web/lib/firebase/groups' +import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + const { slug } = req.query + const group = await getGroupBySlug(slug as string) + if (!group) { + res.status(404).json({ error: 'Group not found' }) + return + } + res.setHeader('Cache-Control', 'no-cache') + return res.status(200).json(group) +} diff --git a/web/pages/api/v0/group/by-id/[id].ts b/web/pages/api/v0/group/by-id/[id].ts new file mode 100644 index 00000000..3260302b --- /dev/null +++ b/web/pages/api/v0/group/by-id/[id].ts @@ -0,0 +1,18 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { getGroup } from 'web/lib/firebase/groups' +import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + const { id } = req.query + const group = await getGroup(id as string) + if (!group) { + res.status(404).json({ error: 'Group not found' }) + return + } + res.setHeader('Cache-Control', 'no-cache') + return res.status(200).json(group) +} diff --git a/web/pages/api/v0/groups.ts b/web/pages/api/v0/groups.ts new file mode 100644 index 00000000..84b773b3 --- /dev/null +++ b/web/pages/api/v0/groups.ts @@ -0,0 +1,15 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import { listAllGroups } from 'web/lib/firebase/groups' +import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' + +type Data = any[] + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse<Data> +) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + const groups = await listAllGroups() + res.setHeader('Cache-Control', 'max-age=0') + res.status(200).json(groups) +} diff --git a/web/pages/api/v0/market/[id]/lite.ts b/web/pages/api/v0/market/[id]/lite.ts new file mode 100644 index 00000000..7688caa8 --- /dev/null +++ b/web/pages/api/v0/market/[id]/lite.ts @@ -0,0 +1,23 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { getContractFromId } from 'web/lib/firebase/contracts' +import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' +import { ApiError, toLiteMarket, LiteMarket } from '../../_types' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse<LiteMarket | ApiError> +) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + const { id } = req.query + const contractId = id as string + + const contract = await getContractFromId(contractId) + + if (!contract) { + res.status(404).json({ error: 'Contract not found' }) + return + } + + res.setHeader('Cache-Control', 'max-age=0') + return res.status(200).json(toLiteMarket(contract)) +} diff --git a/web/pages/api/v0/me.ts b/web/pages/api/v0/me.ts new file mode 100644 index 00000000..7ee3cc3f --- /dev/null +++ b/web/pages/api/v0/me.ts @@ -0,0 +1,16 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { fetchBackend, forwardResponse } from 'web/lib/api/proxy' +import { LiteUser, ApiError } from './_types' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse<LiteUser | ApiError> +) { + try { + const backendRes = await fetchBackend(req, 'getcurrentuser') + await forwardResponse(res, backendRes) + } catch (err) { + console.error('Error talking to cloud function: ', err) + res.status(500).json({ error: 'Error communicating with backend.' }) + } +} diff --git a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx new file mode 100644 index 00000000..55e78616 --- /dev/null +++ b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx @@ -0,0 +1,405 @@ +import React, { useEffect, useState } from 'react' +import Confetti from 'react-confetti' + +import { fromPropz, usePropz } from 'web/hooks/use-propz' +import { contractPath, getContractFromSlug } from 'web/lib/firebase/contracts' +import { useContractWithPreload } from 'web/hooks/use-contract' +import { DOMAIN } from 'common/envs/constants' +import { Col } from 'web/components/layout/col' +import { SiteLink } from 'web/components/site-link' +import { Spacer } from 'web/components/layout/spacer' +import { Row } from 'web/components/layout/row' +import { Challenge } from 'common/challenge' +import { + getChallenge, + getChallengeUrl, + useChallenge, +} from 'web/lib/firebase/challenges' +import { getUserByUsername } from 'web/lib/firebase/users' +import { User } from 'common/user' +import { Page } from 'web/components/page' +import { useUser, useUserById } from 'web/hooks/use-user' +import { AcceptChallengeButton } from 'web/components/challenges/accept-challenge-button' +import { Avatar } from 'web/components/avatar' +import { UserLink } from 'web/components/user-page' +import { BinaryOutcomeLabel } from 'web/components/outcome-label' +import { formatMoney } from 'common/util/format' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { useWindowSize } from 'web/hooks/use-window-size' +import { Bet, listAllBets } from 'web/lib/firebase/bets' +import { SEO } from 'web/components/SEO' +import { getOpenGraphProps } from 'web/components/contract/contract-card-preview' +import Custom404 from 'web/pages/404' +import { useSaveReferral } from 'web/hooks/use-save-referral' +import { BinaryContract } from 'common/contract' +import { Title } from 'web/components/title' + +export const getStaticProps = fromPropz(getStaticPropz) + +export async function getStaticPropz(props: { + params: { username: string; contractSlug: string; challengeSlug: string } +}) { + const { username, contractSlug, challengeSlug } = props.params + const contract = (await getContractFromSlug(contractSlug)) || null + const user = (await getUserByUsername(username)) || null + const bets = contract?.id ? await listAllBets(contract.id) : [] + const challenge = contract?.id + ? await getChallenge(challengeSlug, contract.id) + : null + + return { + props: { + contract, + user, + slug: contractSlug, + challengeSlug, + bets, + challenge, + }, + + revalidate: 60, // regenerate after a minute + } +} + +export async function getStaticPaths() { + return { paths: [], fallback: 'blocking' } +} + +export default function ChallengePage(props: { + contract: BinaryContract | null + user: User + slug: string + bets: Bet[] + challenge: Challenge | null + challengeSlug: string +}) { + props = usePropz(props, getStaticPropz) ?? { + contract: null, + user: null, + challengeSlug: '', + bets: [], + challenge: null, + slug: '', + } + const contract = (useContractWithPreload(props.contract) ?? + props.contract) as BinaryContract + + const challenge = + useChallenge(props.challengeSlug, contract?.id) ?? props.challenge + + const { user, bets } = props + const currentUser = useUser() + + useSaveReferral(currentUser, { + defaultReferrerUsername: challenge?.creatorUsername, + contractId: challenge?.contractId, + }) + + if (!contract || !challenge) return <Custom404 /> + + const ogCardProps = getOpenGraphProps(contract) + ogCardProps.creatorUsername = challenge.creatorUsername + ogCardProps.creatorName = challenge.creatorName + ogCardProps.creatorAvatarUrl = challenge.creatorAvatarUrl + + return ( + <Page> + <SEO + title={ogCardProps.question} + description={ogCardProps.description} + url={getChallengeUrl(challenge).replace('https://', '')} + ogCardProps={ogCardProps} + challenge={challenge} + /> + {challenge.acceptances.length >= challenge.maxUses ? ( + <ClosedChallengeContent + contract={contract} + challenge={challenge} + creator={user} + /> + ) : ( + <OpenChallengeContent + user={currentUser} + contract={contract} + challenge={challenge} + creator={user} + bets={bets} + /> + )} + + <FAQ /> + </Page> + ) +} + +function FAQ() { + const [toggleWhatIsThis, setToggleWhatIsThis] = useState(false) + const [toggleWhatIsMana, setToggleWhatIsMana] = useState(false) + return ( + <Col className={'items-center gap-4 p-2 md:p-6 lg:items-start'}> + <Row className={'text-xl text-indigo-700'}>FAQ</Row> + <Row className={'text-lg text-indigo-700'}> + <span + className={'mx-2 cursor-pointer'} + onClick={() => setToggleWhatIsThis(!toggleWhatIsThis)} + > + {toggleWhatIsThis ? '-' : '+'} + What is this? + </span> + </Row> + {toggleWhatIsThis && ( + <Row className={'mx-4'}> + <span> + This is a challenge bet, or a bet offered from one person to another + that is only realized if both parties agree. You can agree to the + challenge (if it's open) or create your own from a market page. See + more markets{' '} + <SiteLink className={'font-bold'} href={'/home'}> + here. + </SiteLink> + </span> + </Row> + )} + <Row className={'text-lg text-indigo-700'}> + <span + className={'mx-2 cursor-pointer'} + onClick={() => setToggleWhatIsMana(!toggleWhatIsMana)} + > + {toggleWhatIsMana ? '-' : '+'} + What is M$? + </span> + </Row> + {toggleWhatIsMana && ( + <Row className={'mx-4'}> + Mana (M$) is the play-money used by our platform to keep track of your + bets. It's completely free to get started, and you can donate your + winnings to charity! + </Row> + )} + </Col> + ) +} + +function ClosedChallengeContent(props: { + contract: BinaryContract + challenge: Challenge + creator: User +}) { + const { contract, challenge, creator } = props + const { resolution, question } = contract + const { + acceptances, + creatorAmount, + creatorOutcome, + acceptorOutcome, + acceptorAmount, + } = challenge + + const user = useUserById(acceptances[0].userId) + + const [showConfetti, setShowConfetti] = useState(false) + const { width, height } = useWindowSize() + useEffect(() => { + if (acceptances.length === 0) return + if (acceptances[0].createdTime > Date.now() - 1000 * 60) + setShowConfetti(true) + }, [acceptances]) + + const creatorWon = resolution === creatorOutcome + + const href = `https://${DOMAIN}${contractPath(contract)}` + + if (!user) return <LoadingIndicator /> + + const winner = (creatorWon ? creator : user).name + + return ( + <> + {showConfetti && ( + <Confetti + width={width ?? 500} + height={height ?? 500} + confettiSource={{ + x: ((width ?? 500) - 200) / 2, + y: 0, + w: 200, + h: 0, + }} + recycle={false} + initialVelocityY={{ min: 1, max: 3 }} + numberOfPieces={200} + /> + )} + <Col className=" w-full items-center justify-center rounded border-0 border-gray-100 bg-white py-6 pl-1 pr-2 sm:px-2 md:px-6 md:py-8 "> + {resolution ? ( + <> + <Title className="!mt-0" text={`🥇 ${winner} wins the bet 🥇`} /> + <SiteLink href={href} className={'mb-8 text-xl'}> + {question} + </SiteLink> + </> + ) : ( + <SiteLink href={href} className={'mb-8'}> + <span className="text-3xl text-indigo-700">{question}</span> + </SiteLink> + )} + <Col + className={'w-full content-between justify-between gap-1 sm:flex-row'} + > + <UserBetColumn + challenger={creator} + outcome={creatorOutcome} + amount={creatorAmount} + isResolved={!!resolution} + /> + + <Col className="items-center justify-center py-8 text-2xl sm:text-4xl"> + VS + </Col> + + <UserBetColumn + challenger={user?.id === creator.id ? undefined : user} + outcome={acceptorOutcome} + amount={acceptorAmount} + isResolved={!!resolution} + /> + </Col> + <Spacer h={3} /> + + {/* <Row className="mt-8 items-center"> + <span className='mr-4'>Share</span> <CopyLinkButton url={window.location.href} /> + </Row> */} + </Col> + </> + ) +} + +function OpenChallengeContent(props: { + contract: BinaryContract + challenge: Challenge + creator: User + user: User | null | undefined + bets: Bet[] +}) { + const { contract, challenge, creator, user } = props + const { question } = contract + const { + creatorAmount, + creatorId, + creatorOutcome, + acceptorAmount, + acceptorOutcome, + } = challenge + + const href = `https://${DOMAIN}${contractPath(contract)}` + + return ( + <Col className="items-center"> + <Col className="h-full items-center justify-center rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md"> + <SiteLink href={href} className={'mb-8'}> + <span className="text-3xl text-indigo-700">{question}</span> + </SiteLink> + + <Col + className={ + ' w-full content-between justify-between gap-1 sm:flex-row' + } + > + <UserBetColumn + challenger={creator} + outcome={creatorOutcome} + amount={creatorAmount} + /> + + <Col className="items-center justify-center py-4 text-2xl sm:py-8 sm:text-4xl"> + VS + </Col> + + <UserBetColumn + challenger={user?.id === creatorId ? undefined : user} + outcome={acceptorOutcome} + amount={acceptorAmount} + /> + </Col> + + <Spacer h={3} /> + <Row className={'my-4 text-center text-gray-500'}> + <span> + {`${creator.name} will bet ${formatMoney( + creatorAmount + )} on ${creatorOutcome} if you bet ${formatMoney( + acceptorAmount + )} on ${acceptorOutcome}. Whoever is right will get `} + <span className="mr-1 font-bold "> + {formatMoney(creatorAmount + acceptorAmount)} + </span> + total. + </span> + </Row> + + <Row className="my-4 w-full items-center justify-center"> + <AcceptChallengeButton + user={user} + contract={contract} + challenge={challenge} + /> + </Row> + </Col> + </Col> + ) +} + +const userCol = (challenger: User) => ( + <Col className={'mb-2 w-full items-center justify-center gap-2'}> + <UserLink + className={'text-2xl'} + name={challenger.name} + username={challenger.username} + /> + <Avatar + size={24} + avatarUrl={challenger.avatarUrl} + username={challenger.username} + /> + </Col> +) + +function UserBetColumn(props: { + challenger: User | null | undefined + outcome: string + amount: number + isResolved?: boolean +}) { + const { challenger, outcome, amount, isResolved } = props + + return ( + <Col className="w-full items-start justify-center gap-1"> + {challenger ? ( + userCol(challenger) + ) : ( + <Col className={'mb-2 w-full items-center justify-center gap-2'}> + <span className={'text-2xl'}>You</span> + <Avatar + className={'h-[7.25rem] w-[7.25rem]'} + avatarUrl={undefined} + username={undefined} + /> + </Col> + )} + <Row className={'w-full items-center justify-center'}> + <span className={'text-lg'}> + {isResolved ? 'had bet' : challenger ? '' : ''} + </span> + </Row> + <Row className={'w-full items-center justify-center'}> + <span className={'text-lg'}> + <span className="bold text-2xl">{formatMoney(amount)}</span> + {' on '} + <span className="bold text-2xl"> + <BinaryOutcomeLabel outcome={outcome as any} /> + </span>{' '} + </span> + </Row> + </Col> + ) +} diff --git a/web/pages/challenges/index.tsx b/web/pages/challenges/index.tsx new file mode 100644 index 00000000..e548e56f --- /dev/null +++ b/web/pages/challenges/index.tsx @@ -0,0 +1,305 @@ +import clsx from 'clsx' +import React from 'react' +import { formatMoney } from 'common/util/format' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { Page } from 'web/components/page' +import { SEO } from 'web/components/SEO' +import { Title } from 'web/components/title' +import { useUser } from 'web/hooks/use-user' +import { fromNow } from 'web/lib/util/time' + +import dayjs from 'dayjs' +import customParseFormat from 'dayjs/plugin/customParseFormat' +import { + getChallengeUrl, + useAcceptedChallenges, + useUserChallenges, +} from 'web/lib/firebase/challenges' +import { Challenge } from 'common/challenge' +import { Tabs } from 'web/components/layout/tabs' +import { SiteLink } from 'web/components/site-link' +import { UserLink } from 'web/components/user-page' +import { Avatar } from 'web/components/avatar' +import Router from 'next/router' +import { contractPathWithoutContract } from 'web/lib/firebase/contracts' +import { Button } from 'web/components/button' +import { ClipboardCopyIcon, QrcodeIcon } from '@heroicons/react/outline' +import { copyToClipboard } from 'web/lib/util/copy' +import toast from 'react-hot-toast' +import { Modal } from 'web/components/layout/modal' +import { QRCode } from 'web/components/qr-code' + +dayjs.extend(customParseFormat) +const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate' +const amountClass = columnClass + ' max-w-[75px] font-bold' + +export default function ChallengesListPage() { + const user = useUser() + const challenges = useAcceptedChallenges() + const userChallenges = useUserChallenges(user?.id) + .concat( + user ? challenges.filter((c) => c.acceptances[0].userId === user.id) : [] + ) + .sort((a, b) => b.createdTime - a.createdTime) + + const userTab = user + ? [ + { + content: <YourChallengesTable links={userChallenges} />, + title: 'Your Challenges', + }, + ] + : [] + + const publicTab = [ + { + content: <PublicChallengesTable links={challenges} />, + title: 'Public Challenges', + }, + ] + + return ( + <Page> + <SEO + title="Challenges" + description="Challenge your friends to a bet!" + url="/send" + /> + + <Col className="w-full px-8"> + <Row className="items-center justify-between"> + <Title text="Challenges" /> + </Row> + <p>Find or create a question to challenge someone to a bet.</p> + + <Tabs tabs={[...userTab, ...publicTab]} /> + </Col> + </Page> + ) +} + +function YourChallengesTable(props: { links: Challenge[] }) { + const { links } = props + return links.length == 0 ? ( + <p>There aren't currently any challenges.</p> + ) : ( + <div className="overflow-scroll"> + <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> + <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> + <tr> + <th className={amountClass}>Amount</th> + <th + className={clsx( + columnClass, + 'text-center sm:pl-10 sm:text-start' + )} + > + Link + </th> + <th className={columnClass}>Accepted By</th> + </tr> + </thead> + <tbody className={'divide-y divide-gray-200 bg-white'}> + {links.map((link) => ( + <YourLinkSummaryRow challenge={link} /> + ))} + </tbody> + </table> + </div> + ) +} + +function YourLinkSummaryRow(props: { challenge: Challenge }) { + const { challenge } = props + const { acceptances } = challenge + + const [open, setOpen] = React.useState(false) + const className = clsx( + 'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white' + ) + return ( + <> + <Modal open={open} setOpen={setOpen} size={'sm'}> + <Col + className={ + 'items-center justify-center gap-4 rounded-md bg-white p-8 py-8 ' + } + > + <span className={'mb-4 text-center text-xl text-indigo-700'}> + Have your friend scan this to accept the challenge! + </span> + <QRCode url={getChallengeUrl(challenge)} /> + </Col> + </Modal> + <tr id={challenge.slug} key={challenge.slug} className={className}> + <td className={amountClass}> + <SiteLink href={getChallengeUrl(challenge)}> + {formatMoney(challenge.creatorAmount)} + </SiteLink> + </td> + <td + className={clsx( + columnClass, + 'text-center sm:max-w-[200px] sm:text-start' + )} + > + <Row className="items-center gap-2"> + <Button + color="gray-white" + size="xs" + onClick={() => { + copyToClipboard(getChallengeUrl(challenge)) + toast('Link copied to clipboard!') + }} + > + <ClipboardCopyIcon className={'h-5 w-5 sm:h-4 sm:w-4'} /> + </Button> + <Button + color="gray-white" + size="xs" + onClick={() => { + setOpen(true) + }} + > + <QrcodeIcon className="h-5 w-5 sm:h-4 sm:w-4" /> + </Button> + <SiteLink + href={getChallengeUrl(challenge)} + className={'mx-1 mb-1 hidden sm:inline-block'} + > + {`...${challenge.contractSlug}/${challenge.slug}`} + </SiteLink> + </Row> + </td> + + <td className={columnClass}> + <Row className={'items-center justify-start gap-1'}> + {acceptances.length > 0 ? ( + <> + <Avatar + username={acceptances[0].userUsername} + avatarUrl={acceptances[0].userAvatarUrl} + size={'sm'} + /> + <UserLink + name={acceptances[0].userName} + username={acceptances[0].userUsername} + /> + </> + ) : ( + <span> + No one - + {challenge.expiresTime && + ` (expires ${fromNow(challenge.expiresTime)})`} + </span> + )} + </Row> + </td> + </tr> + </> + ) +} + +function PublicChallengesTable(props: { links: Challenge[] }) { + const { links } = props + return links.length == 0 ? ( + <p>There aren't currently any challenges.</p> + ) : ( + <div className="overflow-scroll"> + <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> + <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> + <tr> + <th className={amountClass}>Amount</th> + <th className={columnClass}>Creator</th> + <th className={columnClass}>Acceptor</th> + <th className={columnClass}>Market</th> + </tr> + </thead> + <tbody className={'divide-y divide-gray-200 bg-white'}> + {links.map((link) => ( + <PublicLinkSummaryRow challenge={link} /> + ))} + </tbody> + </table> + </div> + ) +} + +function PublicLinkSummaryRow(props: { challenge: Challenge }) { + const { challenge } = props + const { + acceptances, + creatorUsername, + creatorName, + creatorAvatarUrl, + contractCreatorUsername, + contractQuestion, + contractSlug, + } = challenge + + const className = clsx( + 'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white' + ) + return ( + <tr + id={challenge.slug + '-public'} + key={challenge.slug + '-public'} + className={className} + onClick={() => Router.push(getChallengeUrl(challenge))} + > + <td className={amountClass}> + <SiteLink href={getChallengeUrl(challenge)}> + {formatMoney(challenge.creatorAmount)} + </SiteLink> + </td> + + <td className={clsx(columnClass)}> + <Row className={'items-center justify-start gap-1'}> + <Avatar + username={creatorUsername} + avatarUrl={creatorAvatarUrl} + size={'sm'} + noLink={true} + /> + <UserLink name={creatorName} username={creatorUsername} /> + </Row> + </td> + + <td className={clsx(columnClass)}> + <Row className={'items-center justify-start gap-1'}> + {acceptances.length > 0 ? ( + <> + <Avatar + username={acceptances[0].userUsername} + avatarUrl={acceptances[0].userAvatarUrl} + size={'sm'} + noLink={true} + /> + <UserLink + name={acceptances[0].userName} + username={acceptances[0].userUsername} + /> + </> + ) : ( + <span> + No one - + {challenge.expiresTime && + ` (expires ${fromNow(challenge.expiresTime)})`} + </span> + )} + </Row> + </td> + <td className={clsx(columnClass, 'font-bold')}> + <SiteLink + href={contractPathWithoutContract( + contractCreatorUsername, + contractSlug + )} + > + {contractQuestion} + </SiteLink> + </td> + </tr> + ) +} diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 2d45e831..e2ca308c 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -1,13 +1,11 @@ import { Answer } from 'common/answer' import { searchInAny } from 'common/util/parse' import { sortBy } from 'lodash' -import { useState } from 'react' -import { ContractsGrid } from 'web/components/contract/contracts-list' -import { LoadingIndicator } from 'web/components/loading-indicator' +import { ContractsGrid } from 'web/components/contract/contracts-grid' import { useContracts } from 'web/hooks/use-contracts' import { Sort, - useInitialQueryAndSort, + useQueryAndSortParams, } from 'web/hooks/use-sort-and-query-params' const MAX_CONTRACTS_RENDERED = 100 @@ -20,14 +18,15 @@ export default function ContractSearchFirestore(props: { additionalFilter?: { creatorId?: string tag?: string + excludeContractIds?: string[] + groupSlug?: string } }) { const contracts = useContracts() const { querySortOptions, additionalFilter } = props - const { initialSort, initialQuery } = useInitialQueryAndSort(querySortOptions) - const [sort, setSort] = useState(initialSort || 'newest') - const [query, setQuery] = useState(initialQuery) + const { query, setQuery, sort, setSort } = + useQueryAndSortParams(querySortOptions) let matches = (contracts ?? []).filter((c) => searchInAny( @@ -47,11 +46,7 @@ export default function ContractSearchFirestore(props: { matches.sort((a, b) => a.createdTime - b.createdTime) } else if (sort === 'close-date') { matches = sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours) - matches = sortBy( - matches, - (contract) => - (sort === 'close-date' ? -1 : 1) * (contract.closeTime ?? Infinity) - ) + matches = sortBy(matches, (contract) => contract.closeTime ?? Infinity) } else if (sort === 'most-traded') { matches.sort((a, b) => b.volume - a.volume) } else if (sort === 'score') { @@ -63,7 +58,7 @@ export default function ContractSearchFirestore(props: { } if (additionalFilter) { - const { creatorId, tag } = additionalFilter + const { creatorId, tag, groupSlug, excludeContractIds } = additionalFilter if (creatorId) { matches = matches.filter((c) => c.creatorId === creatorId) @@ -74,6 +69,14 @@ export default function ContractSearchFirestore(props: { c.lowercaseTags.includes(tag.toLowerCase()) ) } + + if (groupSlug) { + matches = matches.filter((c) => c.groupSlugs?.includes(groupSlug)) + } + + if (excludeContractIds) { + matches = matches.filter((c) => !excludeContractIds.includes(c.id)) + } } matches = matches.slice(0, MAX_CONTRACTS_RENDERED) @@ -100,24 +103,19 @@ export default function ContractSearchFirestore(props: { value={sort} onChange={(e) => setSort(e.target.value as Sort)} > + <option value="score">Trending</option> <option value="newest">Newest</option> - <option value="oldest">Oldest</option> - <option value="score">Most popular</option> <option value="most-traded">Most traded</option> <option value="24-hour-vol">24h volume</option> <option value="close-date">Closing soon</option> </select> </div> - {contracts === undefined ? ( - <LoadingIndicator /> - ) : ( - <ContractsGrid - contracts={matches} - loadMore={() => {}} - hasMore={false} - showTime={showTime} - /> - )} + <ContractsGrid + contracts={matches} + loadMore={() => {}} + hasMore={false} + showTime={showTime} + /> </div> ) } diff --git a/web/pages/create.tsx b/web/pages/create.tsx index ca29cba9..19ab2fe0 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -4,7 +4,7 @@ import clsx from 'clsx' import dayjs from 'dayjs' import Textarea from 'react-expanding-textarea' import { Spacer } from 'web/components/layout/spacer' -import { useUser } from 'web/hooks/use-user' +import { getUser } from 'web/lib/firebase/users' import { Contract, contractPath } from 'web/lib/firebase/contracts' import { createMarket } from 'web/lib/firebase/api' import { FIXED_ANTE } from 'common/antes' @@ -19,7 +19,7 @@ import { import { formatMoney } from 'common/util/format' import { removeUndefinedProps } from 'common/util/object' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { getGroup, setContractGroupLinks } from 'web/lib/firebase/groups' +import { canModifyGroupContracts, getGroup } from 'web/lib/firebase/groups' import { Group } from 'common/group' import { useTracking } from 'web/hooks/use-tracking' import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' @@ -33,7 +33,10 @@ import { Title } from 'web/components/title' import { SEO } from 'web/components/SEO' import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers' -export const getServerSideProps = redirectIfLoggedOut('/') +export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { + const user = await getUser(creds.user.uid) + return { props: { user } } +}) type NewQuestionParams = { groupId?: string @@ -49,8 +52,9 @@ type NewQuestionParams = { initValue?: string } -export default function Create() { +export default function Create(props: { user: User }) { useTracking('view create page') + const { user } = props const router = useRouter() const params = router.query as NewQuestionParams // TODO: Not sure why Question is pulled out as its own component; @@ -60,8 +64,7 @@ export default function Create() { setQuestion(params.q ?? '') }, [params.q]) - const creator = useUser() - if (!router.isReady || !creator) return <div /> + if (!router.isReady) return <div /> return ( <Page> @@ -93,7 +96,7 @@ export default function Create() { </div> </form> <Spacer h={6} /> - <NewContract question={question} params={params} creator={creator} /> + <NewContract question={question} params={params} creator={user} /> </div> </div> </Page> @@ -102,7 +105,7 @@ export default function Create() { // Allow user to create a new contract export function NewContract(props: { - creator?: User | null + creator: User question: string params?: NewQuestionParams }) { @@ -120,14 +123,14 @@ export function NewContract(props: { const [answers, setAnswers] = useState<string[]>([]) // for multiple choice useEffect(() => { - if (groupId && creator) + if (groupId) getGroup(groupId).then((group) => { - if (group && group.memberIds.includes(creator.id)) { + if (group && canModifyGroupContracts(group, creator.id)) { setSelectedGroup(group) setShowGroupSelector(false) } }) - }, [creator, groupId]) + }, [creator.id, groupId]) const [ante, _setAnte] = useState(FIXED_ANTE) // If params.closeTime is set, extract out the specified date and time @@ -152,7 +155,7 @@ export function NewContract(props: { ? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf() : undefined - const balance = creator?.balance || 0 + const balance = creator.balance || 0 const min = minString ? parseFloat(minString) : undefined const max = maxString ? parseFloat(maxString) : undefined @@ -214,7 +217,7 @@ export function NewContract(props: { async function submit() { // TODO: Tell users why their contract is invalid - if (!creator || !isValid) return + if (!isValid) return setIsSubmitting(true) try { const result = await createMarket( @@ -239,10 +242,6 @@ export function NewContract(props: { selectedGroup: selectedGroup?.id, isFree: false, }) - if (result && selectedGroup) { - await setContractGroupLinks(selectedGroup, result.id, creator.id) - } - await router.push(contractPath(result as Contract)) } catch (e) { console.error('error creating contract', e, (e as any).details) @@ -253,8 +252,6 @@ export function NewContract(props: { } } - if (!creator) return <></> - return ( <div> <label className="label"> diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 57189c0c..d38c6e5b 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -21,8 +21,11 @@ import { useMeasureSize } from 'web/hooks/use-measure-size' import { fromPropz, usePropz } from 'web/hooks/use-propz' import { useWindowSize } from 'web/hooks/use-window-size' import { listAllBets } from 'web/lib/firebase/bets' -import { contractPath, getContractFromSlug } from 'web/lib/firebase/contracts' -import { tradingAllowed } from 'web/lib/firebase/contracts' +import { + contractPath, + getContractFromSlug, + tradingAllowed, +} from 'web/lib/firebase/contracts' import Custom404 from '../../404' export const getStaticProps = fromPropz(getStaticPropz) @@ -76,7 +79,7 @@ export default function ContractEmbedPage(props: { return <ContractEmbed contract={contract} bets={bets} /> } -function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { +export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props const { question, outcomeType } = contract diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index dd712a36..b96d6436 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -16,7 +16,7 @@ import { Row } from 'web/components/layout/row' import { UserLink } from 'web/components/user-page' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' -import { useUser } from 'web/hooks/use-user' +import { usePrivateUser, useUser } from 'web/hooks/use-user' import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' import { useRouter } from 'next/router' import { scoreCreators, scoreTraders } from 'common/scoring' @@ -30,7 +30,7 @@ import { fromPropz, usePropz } from 'web/hooks/use-propz' import { Tabs } from 'web/components/layout/tabs' import { CreateQuestionButton } from 'web/components/create-question-button' import React, { useState } from 'react' -import { GroupChat } from 'web/components/groups/group-chat' +import { GroupChatInBubble } from 'web/components/groups/group-chat' import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' import { getSavedSort } from 'web/hooks/use-sort-and-query-params' @@ -45,11 +45,12 @@ import { SearchIcon } from '@heroicons/react/outline' import { useTipTxns } from 'web/hooks/use-tip-txns' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { searchInAny } from 'common/util/parse' -import { useWindowSize } from 'web/hooks/use-window-size' import { CopyLinkButton } from 'web/components/copy-link-button' import { ENV_CONFIG } from 'common/envs/constants' import { useSaveReferral } from 'web/hooks/use-save-referral' import { Button } from 'web/components/button' +import { listAllCommentsOnGroup } from 'web/lib/firebase/comments' +import { Comment } from 'common/comment' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -65,6 +66,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const bets = await Promise.all( contracts.map((contract: Contract) => listAllBets(contract.id)) ) + const messages = group && (await listAllCommentsOnGroup(group.id)) const creatorScores = scoreCreators(contracts) const traderScores = scoreTraders(contracts, bets) @@ -86,6 +88,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { topTraders, creatorScores, topCreators, + messages, }, revalidate: 60, // regenerate after a minute @@ -123,6 +126,7 @@ export default function GroupPage(props: { topTraders: User[] creatorScores: { [userId: string]: number } topCreators: User[] + messages: Comment[] }) { props = usePropz(props, getStaticPropz) ?? { group: null, @@ -132,6 +136,7 @@ export default function GroupPage(props: { topTraders: [], creatorScores: {}, topCreators: [], + messages: [], } const { creator, @@ -149,19 +154,18 @@ export default function GroupPage(props: { const group = useGroup(props.group?.id) ?? props.group const tips = useTipTxns({ groupId: group?.id }) - const messages = useCommentsOnGroup(group?.id) + const messages = useCommentsOnGroup(group?.id) ?? props.messages const user = useUser() + const privateUser = usePrivateUser(user?.id) useSaveReferral(user, { - defaultReferrer: creator.username, + defaultReferrerUsername: creator.username, groupId: group?.id, }) - const { width } = useWindowSize() const chatDisabled = !group || group.chatDisabled - const showChatSidebar = !chatDisabled && (width ?? 1280) >= 1280 - const showChatTab = !chatDisabled && !showChatSidebar + const showChatBubble = !chatDisabled if (group === null || !groupSubpages.includes(page) || slugs[2]) { return <Custom404 /> @@ -195,16 +199,6 @@ export default function GroupPage(props: { </Col> ) - const chatTab = ( - <Col className=""> - {messages ? ( - <GroupChat messages={messages} user={user} group={group} tips={tips} /> - ) : ( - <LoadingIndicator /> - )} - </Col> - ) - const questionsTab = ( <ContractSearch querySortOptions={{ @@ -217,15 +211,6 @@ export default function GroupPage(props: { ) const tabs = [ - ...(!showChatTab - ? [] - : [ - { - title: 'Chat', - content: chatTab, - href: groupPath(group.slug, GROUP_CHAT_SLUG), - }, - ]), { title: 'Markets', content: questionsTab, @@ -242,20 +227,17 @@ export default function GroupPage(props: { href: groupPath(group.slug, 'about'), }, ] + const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG) return ( - <Page - rightSidebar={showChatSidebar ? chatTab : undefined} - rightSidebarClassName={showChatSidebar ? '!top-0' : ''} - className={showChatSidebar ? '!max-w-7xl !pb-0' : ''} - > + <Page> <SEO title={group.name} description={`Created by ${creator.name}. ${group.about}`} url={groupPath(group.slug)} /> - <Col className="px-3"> + <Col className="relative px-3"> <Row className={'items-center justify-between gap-4'}> <div className={'sm:mb-1'}> <div @@ -282,6 +264,15 @@ export default function GroupPage(props: { defaultIndex={tabIndex > 0 ? tabIndex : 0} tabs={tabs} /> + {showChatBubble && ( + <GroupChatInBubble + group={group} + user={user} + privateUser={privateUser} + tips={tips} + messages={messages} + /> + )} </Page> ) } diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 61003895..ab915ae3 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -81,11 +81,18 @@ const useContractPage = () => { if (!username || !contractSlug) setContract(undefined) else { // Show contract if route is to a contract: '/[username]/[contractSlug]'. - getContractFromSlug(contractSlug).then(setContract) + getContractFromSlug(contractSlug).then((contract) => { + const path = location.pathname.split('/').slice(1) + const [_username, contractSlug] = path + // Make sure we're still on the same contract. + if (contract?.slug === contractSlug) setContract(contract) + }) } } } + addEventListener('popstate', updateContract) + const { pushState, replaceState } = window.history window.history.pushState = function () { @@ -101,6 +108,7 @@ const useContractPage = () => { } return () => { + removeEventListener('popstate', updateContract) window.history.pushState = pushState window.history.replaceState = replaceState } diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index 119fec77..d2b12065 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -1,14 +1,17 @@ import { useRouter } from 'next/router' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { SEO } from 'web/components/SEO' import { Title } from 'web/components/title' import { claimManalink } from 'web/lib/firebase/api' import { useManalink } from 'web/lib/firebase/manalinks' import { ManalinkCard } from 'web/components/manalink-card' import { useUser } from 'web/hooks/use-user' -import { firebaseLogin } from 'web/lib/firebase/users' +import { firebaseLogin, getUser } from 'web/lib/firebase/users' import { Row } from 'web/components/layout/row' import { Button } from 'web/components/button' +import { useSaveReferral } from 'web/hooks/use-save-referral' +import { User } from 'common/user' +import { Manalink } from 'common/manalink' export default function ClaimPage() { const user = useUser() @@ -18,6 +21,8 @@ export default function ClaimPage() { const [claiming, setClaiming] = useState(false) const [error, setError] = useState<string | undefined>(undefined) + useReferral(user, manalink) + if (!manalink) { return <></> } @@ -33,46 +38,58 @@ export default function ClaimPage() { <div className="mx-auto max-w-xl px-2"> <Row className="items-center justify-between"> <Title text={`Claim M$${manalink.amount} mana`} /> - <div className="my-auto"> - <Button - onClick={async () => { - setClaiming(true) - try { - if (user == null) { - await firebaseLogin() - setClaiming(false) - return - } - if (user?.id == manalink.fromId) { - throw new Error("You can't claim your own manalink.") - } - await claimManalink({ slug: manalink.slug }) - user && router.push(`/${user.username}?claimed-mana=yes`) - } catch (e) { - console.log(e) - const message = - e && e instanceof Object - ? e.toString() - : 'An error occurred.' - setError(message) - } - setClaiming(false) - }} - disabled={claiming} - size="lg" - > - {user ? 'Claim' : 'Login'} - </Button> - </div> + <div className="my-auto"></div> </Row> + <ManalinkCard info={info} /> + {error && ( <section className="my-5 text-red-500"> <p>Failed to claim manalink.</p> <p>{error}</p> </section> )} + + <Row className="items-center"> + <Button + onClick={async () => { + setClaiming(true) + try { + if (user == null) { + await firebaseLogin() + setClaiming(false) + return + } + if (user?.id == manalink.fromId) { + throw new Error("You can't claim your own manalink.") + } + await claimManalink({ slug: manalink.slug }) + user && router.push(`/${user.username}?claimed-mana=yes`) + } catch (e) { + console.log(e) + const message = + e && e instanceof Object ? e.toString() : 'An error occurred.' + setError(message) + } + setClaiming(false) + }} + disabled={claiming} + size="lg" + > + {user ? `Claim M$${manalink.amount}` : 'Login to claim'} + </Button> + </Row> </div> </> ) } + +const useReferral = (user: User | undefined | null, manalink?: Manalink) => { + const [creator, setCreator] = useState<User | undefined>(undefined) + + useEffect(() => { + if (manalink?.fromId) getUser(manalink.fromId).then(setCreator) + }, [manalink]) + + useSaveReferral(user, { defaultReferrerUsername: creator?.username }) +} diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 0f91d70c..55939b19 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -1,4 +1,9 @@ import { useState } from 'react' + +import dayjs from 'dayjs' +import customParseFormat from 'dayjs/plugin/customParseFormat' +dayjs.extend(customParseFormat) + import { formatMoney } from 'common/util/format' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' @@ -6,33 +11,36 @@ import { Page } from 'web/components/page' import { SEO } from 'web/components/SEO' import { Title } from 'web/components/title' import { Subtitle } from 'web/components/subtitle' -import { useUser } from 'web/hooks/use-user' +import { getUser } from 'web/lib/firebase/users' import { useUserManalinks } from 'web/lib/firebase/manalinks' import { useUserById } from 'web/hooks/use-user' import { ManalinkTxn } from 'common/txn' +import { User } from 'common/user' import { Avatar } from 'web/components/avatar' import { RelativeTimestamp } from 'web/components/relative-timestamp' import { UserLink } from 'web/components/user-page' import { CreateLinksButton } from 'web/components/manalinks/create-links-button' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' -import dayjs from 'dayjs' -import customParseFormat from 'dayjs/plugin/customParseFormat' import { ManalinkCardFromView } from 'web/components/manalink-card' import { Pagination } from 'web/components/pagination' import { Manalink } from 'common/manalink' -dayjs.extend(customParseFormat) +import { REFERRAL_AMOUNT } from 'common/user' const LINKS_PER_PAGE = 24 -export const getServerSideProps = redirectIfLoggedOut('/') + +export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { + const user = await getUser(creds.user.uid) + return { props: { user } } +}) export function getManalinkUrl(slug: string) { return `${location.protocol}//${location.host}/link/${slug}` } -export default function LinkPage() { - const user = useUser() - const links = useUserManalinks(user?.id ?? '') +export default function LinkPage(props: { user: User }) { + const { user } = props + const links = useUserManalinks(user.id ?? '') // const manalinkTxns = useManalinkTxns(user?.id ?? '') const [highlightedSlug, setHighlightedSlug] = useState('') const unclaimedLinks = links.filter( @@ -41,10 +49,6 @@ export default function LinkPage() { (l.expiresTime == null || l.expiresTime > Date.now()) ) - if (user == null) { - return null - } - return ( <Page> <SEO @@ -64,8 +68,10 @@ export default function LinkPage() { )} </Row> <p> - You can use manalinks to send mana to other people, even if they - don't yet have a Manifold account. + You can use manalinks to send mana (M$) to other people, even if they + don't yet have a Manifold account. Manalinks are also eligible + for the referral bonus. Invite a new user to Manifold and get M$ + {REFERRAL_AMOUNT} if they sign up! </p> <Subtitle text="Your Manalinks" /> <ManalinksDisplay diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 9f076c41..69139f9c 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,5 +1,4 @@ import { Tabs } from 'web/components/layout/tabs' -import { usePrivateUser } from 'web/hooks/use-user' import React, { useEffect, useMemo, useState } from 'react' import { Notification, notification_source_types } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' @@ -13,9 +12,8 @@ import { MANIFOLD_AVATAR_URL, MANIFOLD_USERNAME, PrivateUser, - User, } from 'common/user' -import { getUser } from 'web/lib/firebase/users' +import { getPrivateUser } from 'web/lib/firebase/users' import clsx from 'clsx' import { RelativeTimestamp } from 'web/components/relative-timestamp' import { Linkify } from 'web/components/linkify' @@ -35,15 +33,11 @@ import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' import { groupBy, sum, uniq } from 'lodash' -import Custom404 from 'web/pages/404' import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' import { useWindowSize } from 'web/hooks/use-window-size' import { safeLocalStorage } from 'web/lib/util/local' -import { - getServerAuthenticatedUid, - redirectIfLoggedOut, -} from 'web/lib/firebase/server-auth' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { SiteLink } from 'web/components/site-link' import { NotificationSettings } from 'web/components/NotificationSettings' @@ -51,18 +45,13 @@ export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' const HIGHLIGHT_CLASS = 'bg-indigo-50' -export const getServerSideProps = redirectIfLoggedOut('/', async (ctx) => { - const uid = await getServerAuthenticatedUid(ctx) - if (!uid) { - return { props: { user: null } } - } - const user = await getUser(uid) - return { props: { user } } +export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { + const privateUser = await getPrivateUser(creds.user.uid) + return { props: { privateUser } } }) -export default function Notifications(props: { user: User }) { - const { user } = props - const privateUser = usePrivateUser(user?.id) +export default function Notifications(props: { privateUser: PrivateUser }) { + const { privateUser } = props const local = safeLocalStorage() let localNotifications = [] as Notification[] const localSavedNotificationGroups = local?.getItem('notification-groups') @@ -74,7 +63,6 @@ export default function Notifications(props: { user: User }) { .flat() } - if (!user) return <Custom404 /> return ( <Page> <div className={'px-2 pt-4 sm:px-4 lg:pt-0'}> @@ -88,17 +76,11 @@ export default function Notifications(props: { user: User }) { tabs={[ { title: 'Notifications', - content: privateUser ? ( + content: ( <NotificationsList privateUser={privateUser} cachedNotifications={localNotifications} /> - ) : ( - <div className={'min-h-[100vh]'}> - <RenderNotificationGroups - notificationGroups={localNotificationGroups} - /> - </div> ), }, { @@ -811,6 +793,7 @@ function getSourceUrl(notification: Notification) { if (sourceType === 'tip' && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}` + if (sourceType === 'challenge') return `${sourceSlug}` if (sourceContractCreatorUsername && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( sourceId ?? '', @@ -913,6 +896,15 @@ function NotificationTextLabel(props: { <span>of your limit order was filled</span> </> ) + } else if (sourceType === 'challenge' && sourceText) { + return ( + <> + <span> for </span> + <span className="text-primary"> + {formatMoney(parseInt(sourceText))} + </span> + </> + ) } return ( <div className={className ? className : 'line-clamp-4 whitespace-pre-line'}> @@ -967,6 +959,9 @@ function getReasonForShowingNotification( case 'bet': reasonText = 'bet against you' break + case 'challenge': + reasonText = 'accepted your challenge' + break default: reasonText = '' } diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index 541f5de9..42bcb5c3 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -1,25 +1,35 @@ -import React, { useEffect, useState } from 'react' +import React, { useState } from 'react' import { RefreshIcon } from '@heroicons/react/outline' import { AddFundsButton } from 'web/components/add-funds-button' import { Page } from 'web/components/page' import { SEO } from 'web/components/SEO' import { Title } from 'web/components/title' -import { usePrivateUser, useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { cleanDisplayName, cleanUsername } from 'common/util/clean-username' import { changeUserInfo } from 'web/lib/firebase/api' import { uploadImage } from 'web/lib/firebase/storage' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' -import { User } from 'common/user' -import { updateUser, updatePrivateUser } from 'web/lib/firebase/users' +import { User, PrivateUser } from 'common/user' +import { + getUser, + getPrivateUser, + updateUser, + updatePrivateUser, +} from 'web/lib/firebase/users' import { defaultBannerUrl } from 'web/components/user-page' import { SiteLink } from 'web/components/site-link' import Textarea from 'react-expanding-textarea' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' -export const getServerSideProps = redirectIfLoggedOut('/') +export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { + const [user, privateUser] = await Promise.all([ + getUser(creds.user.uid), + getPrivateUser(creds.user.uid), + ]) + return { props: { user, privateUser } } +}) function EditUserField(props: { user: User @@ -58,64 +68,45 @@ function EditUserField(props: { ) } -export default function ProfilePage() { - const user = useUser() - const privateUser = usePrivateUser(user?.id) - - const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl || '') +export default function ProfilePage(props: { + user: User + privateUser: PrivateUser +}) { + const { user, privateUser } = props + const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || '') const [avatarLoading, setAvatarLoading] = useState(false) - const [name, setName] = useState(user?.name || '') - const [username, setUsername] = useState(user?.username || '') - const [apiKey, setApiKey] = useState(privateUser?.apiKey || '') - - useEffect(() => { - if (user) { - setAvatarUrl(user.avatarUrl || '') - setName(user.name || '') - setUsername(user.username || '') - } - }, [user]) - - useEffect(() => { - if (privateUser) { - setApiKey(privateUser.apiKey || '') - } - }, [privateUser]) + const [name, setName] = useState(user.name) + const [username, setUsername] = useState(user.username) + const [apiKey, setApiKey] = useState(privateUser.apiKey || '') const updateDisplayName = async () => { const newName = cleanDisplayName(name) - if (newName) { setName(newName) - await changeUserInfo({ name: newName }).catch((_) => - setName(user?.name || '') - ) + await changeUserInfo({ name: newName }).catch((_) => setName(user.name)) } else { - setName(user?.name || '') + setName(user.name) } } const updateUsername = async () => { const newUsername = cleanUsername(username) - if (newUsername) { setUsername(newUsername) await changeUserInfo({ username: newUsername }).catch((_) => - setUsername(user?.username || '') + setUsername(user.username) ) } else { - setUsername(user?.username || '') + setUsername(user.username) } } const updateApiKey = async (e: React.MouseEvent) => { const newApiKey = crypto.randomUUID() - if (user?.id != null) { - setApiKey(newApiKey) - await updatePrivateUser(user.id, { apiKey: newApiKey }).catch(() => { - setApiKey(privateUser?.apiKey || '') - }) - } + setApiKey(newApiKey) + await updatePrivateUser(user.id, { apiKey: newApiKey }).catch(() => { + setApiKey(privateUser.apiKey || '') + }) e.preventDefault() } @@ -124,7 +115,7 @@ export default function ProfilePage() { setAvatarLoading(true) - await uploadImage(user?.username || 'default', file) + await uploadImage(user.username, file) .then(async (url) => { await changeUserInfo({ avatarUrl: url }) setAvatarUrl(url) @@ -132,14 +123,10 @@ export default function ProfilePage() { }) .catch(() => { setAvatarLoading(false) - setAvatarUrl(user?.avatarUrl || '') + setAvatarUrl(user.avatarUrl || '') }) } - if (user == null) { - return <></> - } - return ( <Page> <SEO title="Profile" description="User profile settings" url="/profile" /> @@ -147,7 +134,7 @@ export default function ProfilePage() { <Col className="max-w-lg rounded bg-white p-6 shadow-md sm:mx-auto"> <Row className="justify-between"> <Title className="!mt-0" text="Edit Profile" /> - <SiteLink className="btn btn-primary" href={`/${user?.username}`}> + <SiteLink className="btn btn-primary" href={`/${user.username}`}> Done </SiteLink> </Row> @@ -192,54 +179,53 @@ export default function ProfilePage() { /> </div> - {user && ( - <> - {/* TODO: Allow users with M$ 2000 of assets to set custom banners */} - {/* <EditUserField + {/* TODO: Allow users with M$ 2000 of assets to set custom banners */} + {/* <EditUserField user={user} field="bannerUrl" label="Banner Url" isEditing={isEditing} /> */} - <label className="label"> - Banner image{' '} - <span className="text-sm text-gray-400"> - Not editable for now - </span> - </label> - <div - className="h-32 w-full bg-cover bg-center sm:h-40" - style={{ - backgroundImage: `url(${ - user.bannerUrl || defaultBannerUrl(user.id) - })`, - }} - /> + <label className="label"> + Banner image{' '} + <span className="text-sm text-gray-400">Not editable for now</span> + </label> + <div + className="h-32 w-full bg-cover bg-center sm:h-40" + style={{ + backgroundImage: `url(${ + user.bannerUrl || defaultBannerUrl(user.id) + })`, + }} + /> - {( - [ - ['bio', 'Bio'], - ['website', 'Website URL'], - ['twitterHandle', 'Twitter'], - ['discordHandle', 'Discord'], - ] as const - ).map(([field, label]) => ( - <EditUserField user={user} field={field} label={label} /> - ))} - </> - )} + {( + [ + ['bio', 'Bio'], + ['website', 'Website URL'], + ['twitterHandle', 'Twitter'], + ['discordHandle', 'Discord'], + ] as const + ).map(([field, label]) => ( + <EditUserField + key={field} + user={user} + field={field} + label={label} + /> + ))} <div> <label className="label">Email</label> <div className="ml-1 text-gray-500"> - {privateUser?.email ?? '\u00a0'} + {privateUser.email ?? '\u00a0'} </div> </div> <div> <label className="label">Balance</label> <Row className="ml-1 items-start gap-4 text-gray-500"> - {formatMoney(user?.balance || 0)} + {formatMoney(user.balance)} <AddFundsButton /> </Row> </div> diff --git a/web/pages/trades.tsx b/web/pages/trades.tsx deleted file mode 100644 index a29fb7f0..00000000 --- a/web/pages/trades.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import Router from 'next/router' -import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' - -export const getServerSideProps = redirectIfLoggedOut('/') - -// Deprecated: redirects to /portfolio. -// Eventually, this will be removed. -export default function TradesPage() { - Router.replace('/portfolio') -} diff --git a/web/public/welcome/charity.mp4 b/web/public/welcome/charity.mp4 new file mode 100755 index 00000000..e9ba5a8a Binary files /dev/null and b/web/public/welcome/charity.mp4 differ diff --git a/web/public/welcome/mana-example.mp4 b/web/public/welcome/mana-example.mp4 new file mode 100755 index 00000000..bb28a4bd Binary files /dev/null and b/web/public/welcome/mana-example.mp4 differ diff --git a/web/public/welcome/manipurple.png b/web/public/welcome/manipurple.png new file mode 100644 index 00000000..97d361b9 Binary files /dev/null and b/web/public/welcome/manipurple.png differ diff --git a/web/public/welcome/treasure.png b/web/public/welcome/treasure.png new file mode 100644 index 00000000..9c590d63 Binary files /dev/null and b/web/public/welcome/treasure.png differ diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 3457b7a6..5fbc6c15 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -18,6 +18,15 @@ module.exports = { backgroundImage: { 'world-trading': "url('/world-trading-background.webp')", }, + colors: { + 'greyscale-1': '#FBFBFF', + 'greyscale-2': '#E7E7F4', + 'greyscale-3': '#D8D8EB', + 'greyscale-4': '#B1B1C7', + 'greyscale-5': '#9191A7', + 'greyscale-6': '#66667C', + 'greyscale-7': '#111140', + }, typography: { quoteless: { css: { diff --git a/yarn.lock b/yarn.lock index 9334b737..bbf8d3ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5144,6 +5144,11 @@ dayjs@1.10.7: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468" integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig== +dayjs@1.11.4: + version "1.11.4" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.4.tgz#3b3c10ca378140d8917e06ebc13a4922af4f433e" + integrity sha512-Zj/lPM5hOvQ1Bf7uAvewDaUcsJoI6JmNqmHhHl3nyumwe0XHwt8sWdOVAPACJzCebL8gQCi+K49w7iKWnGwX9g== + debug@2, debug@2.6.9, debug@^2.6.0, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

+ This e-mail has been sent to {{name}}, + click here to unsubscribe. +

+
+
+
+
+ +
+
+ + + + diff --git a/functions/src/emails.ts b/functions/src/emails.ts index a29f982c..a097393e 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -17,6 +17,7 @@ import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail } from './send-email' import { getPrivateUser, getUser } from './utils' import { getFunctionUrl } from '../../common/api' +import { richTextToString } from '../../common/util/parse' const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') @@ -165,7 +166,6 @@ export const sendWelcomeEmail = async ( ) } -// TODO: use manalinks to give out M$500 export const sendOneWeekBonusEmail = async ( user: User, privateUser: PrivateUser @@ -185,12 +185,12 @@ export const sendOneWeekBonusEmail = async ( await sendTemplateEmail( privateUser.email, - 'Manifold one week anniversary gift', + 'Manifold Markets one week anniversary gift', 'one-week', { name: firstName, unsubscribeLink, - manalink: '', // TODO + manalink: 'https://manifold.markets/link/lj4JbBvE', }, { from: 'David from Manifold ', @@ -292,7 +292,8 @@ export const sendNewCommentEmail = async ( const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator - const { text } = comment + const { content } = comment + const text = richTextToString(content) let betDescription = '' if (bet) { diff --git a/functions/src/get-current-user.ts b/functions/src/get-current-user.ts new file mode 100644 index 00000000..409f897f --- /dev/null +++ b/functions/src/get-current-user.ts @@ -0,0 +1,18 @@ +import { User } from 'common/user' +import * as admin from 'firebase-admin' +import { newEndpoint, APIError } from './api' + +export const getcurrentuser = newEndpoint( + { method: 'GET' }, + async (_req, auth) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const [userSnap] = await firestore.getAll(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found.') + + const user = userSnap.data() as User + + return user + } +) + +const firestore = admin.firestore() diff --git a/functions/src/get-custom-token.ts b/functions/src/get-custom-token.ts new file mode 100644 index 00000000..4aaaac11 --- /dev/null +++ b/functions/src/get-custom-token.ts @@ -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) + } + }, +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 239806de..07b37648 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -27,6 +27,25 @@ export * from './on-delete-group' export * from './score-contracts' // v2 +export * from './health' +export * from './transact' +export * from './change-user-info' +export * from './create-user' +export * from './create-answer' +export * from './place-bet' +export * from './cancel-bet' +export * from './sell-bet' +export * from './sell-shares' +export * from './claim-manalink' +export * from './create-contract' +export * from './add-liquidity' +export * from './withdraw-liquidity' +export * from './create-group' +export * from './resolve-market' +export * from './unsubscribe' +export * from './stripe' +export * from './mana-bonus-email' + import { health } from './health' import { transact } from './transact' import { changeuserinfo } from './change-user-info' @@ -44,6 +63,9 @@ import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' +import { getcurrentuser } from './get-current-user' +import { acceptchallenge } from './accept-challenge' +import { getcustomtoken } from './get-custom-token' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -66,6 +88,9 @@ const resolveMarketFunction = toCloudFunction(resolvemarket) const unsubscribeFunction = toCloudFunction(unsubscribe) const stripeWebhookFunction = toCloudFunction(stripewebhook) const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) +const getCurrentUserFunction = toCloudFunction(getcurrentuser) +const acceptChallenge = toCloudFunction(acceptchallenge) +const getCustomTokenFunction = toCloudFunction(getcustomtoken) export { healthFunction as health, @@ -86,4 +111,7 @@ export { unsubscribeFunction as unsubscribe, stripeWebhookFunction as stripewebhook, createCheckoutSessionFunction as createcheckoutsession, + getCurrentUserFunction as getcurrentuser, + acceptChallenge as acceptchallenge, + getCustomTokenFunction as getcustomtoken, } diff --git a/functions/src/mana-bonus-email.ts b/functions/src/mana-bonus-email.ts new file mode 100644 index 00000000..29a7e6e0 --- /dev/null +++ b/functions/src/mana-bonus-email.ts @@ -0,0 +1,42 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import * as dayjs from 'dayjs' + +import { getPrivateUser } from './utils' +import { sendOneWeekBonusEmail } from './emails' +import { User } from 'common/user' + +export const manabonusemail = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .pubsub.schedule('0 9 * * 1-7') + .onRun(async () => { + await sendOneWeekEmails() + }) + +const firestore = admin.firestore() + +async function sendOneWeekEmails() { + const oneWeekAgo = dayjs().subtract(1, 'week').valueOf() + const twoWeekAgo = dayjs().subtract(2, 'weeks').valueOf() + + const userDocs = await firestore + .collection('users') + .where('createdTime', '<=', oneWeekAgo) + .get() + + for (const user of userDocs.docs.map((d) => d.data() as User)) { + if (user.createdTime < twoWeekAgo) continue + + const privateUser = await getPrivateUser(user.id) + if (!privateUser || privateUser.manaBonusEmailSent) continue + + await firestore + .collection('private-users') + .doc(user.id) + .update({ manaBonusEmailSent: true }) + + console.log('sending m$ bonus email to', user.username) + await sendOneWeekBonusEmail(user, privateUser) + return + } +} diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 8d841ac0..d7aa0c5e 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -1,13 +1,13 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { uniq } from 'lodash' - +import { compact, uniq } from 'lodash' import { getContract, getUser, getValues } from './utils' import { Comment } from '../../common/comment' import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' import { createNotification } from './create-notification' +import { parseMentions, richTextToString } from '../../common/util/parse' const firestore = admin.firestore() @@ -68,18 +68,22 @@ export const onCreateCommentOnContract = functions ? 'answer' : undefined - const relatedUserId = comment.replyToCommentId + const repliedUserId = comment.replyToCommentId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId + const recipients = uniq( + compact([...parseMentions(comment.content), repliedUserId]) + ) + await createNotification( comment.id, 'comment', 'created', commentCreator, eventId, - comment.text, - { contract, relatedSourceType, relatedUserId } + richTextToString(comment.content), + { contract, relatedSourceType, recipients } ) const recipientUserIds = uniq([ diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index a43beda7..6b57a9a0 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -2,7 +2,7 @@ import * as functions from 'firebase-functions' import { getUser } from './utils' import { createNotification } from './create-notification' import { Contract } from '../../common/contract' -import { richTextToString } from '../../common/util/parse' +import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' export const onCreateContract = functions.firestore @@ -14,13 +14,16 @@ export const onCreateContract = functions.firestore const contractCreator = await getUser(contract.creatorId) if (!contractCreator) throw new Error('Could not find contract creator') + const desc = contract.description as JSONContent + const mentioned = parseMentions(desc) + await createNotification( contract.id, 'contract', 'created', contractCreator, eventId, - richTextToString(contract.description as JSONContent), - { contract } + richTextToString(desc), + { contract, recipients: mentioned } ) }) diff --git a/functions/src/on-create-group.ts b/functions/src/on-create-group.ts index 47618d7a..5209788d 100644 --- a/functions/src/on-create-group.ts +++ b/functions/src/on-create-group.ts @@ -12,19 +12,17 @@ export const onCreateGroup = functions.firestore const groupCreator = await getUser(group.creatorId) if (!groupCreator) throw new Error('Could not find group creator') // create notifications for all members of the group - for (const memberId of group.memberIds) { - await createNotification( - group.id, - 'group', - 'created', - groupCreator, - eventId, - group.about, - { - relatedUserId: memberId, - slug: group.slug, - title: group.name, - } - ) - } + await createNotification( + group.id, + 'group', + 'created', + groupCreator, + eventId, + group.about, + { + recipients: group.memberIds, + slug: group.slug, + title: group.name, + } + ) }) diff --git a/functions/src/on-follow-user.ts b/functions/src/on-follow-user.ts index 9a6e6dce..52042345 100644 --- a/functions/src/on-follow-user.ts +++ b/functions/src/on-follow-user.ts @@ -30,7 +30,7 @@ export const onFollowUser = functions.firestore followingUser, eventId, '', - { relatedUserId: follow.userId } + { recipients: [follow.userId] } ) }) diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index 3ab2a249..7e6a5697 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -1,6 +1,8 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { Group } from '../../common/group' +import { getContract } from './utils' +import { uniq } from 'lodash' const firestore = admin.firestore() export const onUpdateGroup = functions.firestore @@ -9,7 +11,7 @@ export const onUpdateGroup = functions.firestore const prevGroup = change.before.data() as Group const group = change.after.data() as Group - // ignore the update we just made + // Ignore the activity update we just made if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) return @@ -27,3 +29,23 @@ export const onUpdateGroup = functions.firestore .doc(group.id) .update({ mostRecentActivityTime: Date.now() }) }) + +export async function removeGroupLinks(group: Group, contractIds: string[]) { + for (const contractId of contractIds) { + const contract = await getContract(contractId) + await firestore + .collection('contracts') + .doc(contractId) + .update({ + groupSlugs: uniq([ + ...(contract?.groupSlugs?.filter((slug) => slug !== group.slug) ?? + []), + ]), + groupLinks: [ + ...(contract?.groupLinks?.filter( + (link) => link.groupId !== group.id + ) ?? []), + ], + }) + } +} diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 08778a41..cc07d4be 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -18,6 +18,7 @@ import { groupPayoutsByUser, Payout, } from '../../common/payouts' +import { isAdmin } from '../../common/envs/constants' import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' import { APIError, newEndpoint, validate } from './api' @@ -69,8 +70,6 @@ const opts = { secrets: ['MAILGUN_KEY'] } export const resolvemarket = newEndpoint(opts, async (req, auth) => { const { contractId } = validate(bodySchema, req.body) - const userId = auth.uid - const contractDoc = firestore.doc(`contracts/${contractId}`) const contractSnap = await contractDoc.get() if (!contractSnap.exists) @@ -83,7 +82,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { req.body ) - if (creatorId !== userId) + if (creatorId !== auth.uid && !isAdmin(auth.uid)) throw new APIError(403, 'User is not creator of contract') if (contract.resolution) throw new APIError(400, 'Contract already resolved') diff --git a/functions/src/scripts/backfill-group-ids.ts b/functions/src/scripts/backfill-group-ids.ts new file mode 100644 index 00000000..ddce5d99 --- /dev/null +++ b/functions/src/scripts/backfill-group-ids.ts @@ -0,0 +1,25 @@ +// We have some groups without IDs. Let's fill them in. + +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' +import { log, writeAsync } from '../utils' + +initAdmin() +const firestore = admin.firestore() + +if (require.main === module) { + const groupsQuery = firestore.collection('groups') + groupsQuery.get().then(async (groupSnaps) => { + log(`Loaded ${groupSnaps.size} groups.`) + const needsFilling = groupSnaps.docs.filter((ct) => { + return !('id' in ct.data()) + }) + log(`${needsFilling.length} groups need IDs.`) + const updates = needsFilling.map((group) => { + return { doc: group.ref, fields: { id: group.id } } + }) + log(`Updating ${updates.length} groups.`) + await writeAsync(firestore, updates) + log(`Updated all groups.`) + }) +} diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index b6238434..ec08ab86 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -1,4 +1,4 @@ -import { sumBy, uniq } from 'lodash' +import { mapValues, groupBy, sumBy, uniq } from 'lodash' import * as admin from 'firebase-admin' import { z } from 'zod' @@ -9,7 +9,7 @@ import { getCpmmSellBetInfo } from '../../common/sell-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' import { getValues, log } from './utils' import { Bet } from '../../common/bet' -import { floatingLesserEqual } from '../../common/util/math' +import { floatingEqual, floatingLesserEqual } from '../../common/util/math' import { getUnfilledBetsQuery, updateMakers } from './place-bet' import { FieldValue } from 'firebase-admin/firestore' import { redeemShares } from './redeem-shares' @@ -17,7 +17,7 @@ import { redeemShares } from './redeem-shares' const bodySchema = z.object({ contractId: z.string(), shares: z.number().optional(), // leave it out to sell all shares - outcome: z.enum(['YES', 'NO']), + outcome: z.enum(['YES', 'NO']).optional(), // leave it out to sell whichever you have }) export const sellshares = newEndpoint({}, async (req, auth) => { @@ -46,9 +46,31 @@ export const sellshares = newEndpoint({}, async (req, auth) => { throw new APIError(400, 'Trading is closed.') const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0) + const betsByOutcome = groupBy(userBets, (bet) => bet.outcome) + const sharesByOutcome = mapValues(betsByOutcome, (bets) => + sumBy(bets, (b) => b.shares) + ) - const outcomeBets = userBets.filter((bet) => bet.outcome == outcome) - const maxShares = sumBy(outcomeBets, (bet) => bet.shares) + let chosenOutcome: 'YES' | 'NO' + if (outcome != null) { + chosenOutcome = outcome + } else { + const nonzeroShares = Object.entries(sharesByOutcome).filter( + ([_k, v]) => !floatingEqual(0, v) + ) + if (nonzeroShares.length == 0) { + throw new APIError(400, "You don't own any shares in this market.") + } + if (nonzeroShares.length > 1) { + throw new APIError( + 400, + `You own multiple kinds of shares, but did not specify which to sell.` + ) + } + chosenOutcome = nonzeroShares[0][0] as 'YES' | 'NO' + } + + const maxShares = sharesByOutcome[chosenOutcome] const sharesToSell = shares ?? maxShares if (!floatingLesserEqual(sharesToSell, maxShares)) @@ -63,7 +85,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( soldShares, - outcome, + chosenOutcome, contract, prevLoanAmount, unfilledBets diff --git a/functions/src/send-email.ts b/functions/src/send-email.ts index f97234f6..d081997f 100644 --- a/functions/src/send-email.ts +++ b/functions/src/send-email.ts @@ -26,9 +26,10 @@ export const sendTemplateEmail = ( subject: string, templateId: string, templateData: Record, - options?: { from: string } + options?: Partial ) => { - const data = { + const data: mailgun.messages.SendTemplateData = { + ...options, from: options?.from ?? 'Manifold Markets ', to, subject, @@ -36,6 +37,7 @@ export const sendTemplateEmail = ( 'h:X-Mailgun-Variables': JSON.stringify(templateData), } const mg = initMailgun() + return mg.messages().send(data, (error) => { if (error) console.log('Error sending email', error) else console.log('Sent template email', templateId, to, subject) diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 77282951..bf96db20 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -25,6 +25,8 @@ import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' +import { getcurrentuser } from './get-current-user' +import { getcustomtoken } from './get-custom-token' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -62,6 +64,8 @@ addJsonEndpointRoute('/creategroup', creategroup) addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) +addJsonEndpointRoute('/getcurrentuser', getcurrentuser) +addEndpointRoute('/getcustomtoken', getcustomtoken) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) app.listen(PORT) diff --git a/og-image/README.md b/og-image/README.md index 7d0d2f92..6ecc4e82 100644 --- a/og-image/README.md +++ b/og-image/README.md @@ -1,32 +1,35 @@ +# Installing +1. `yarn install` +2. `yarn start` +3. `Y` to `Set up and develop “~path/to/the/repo/manifold”? [Y/n]` +4. `Manifold Markets` to `Which scope should contain your project? [Y/n] ` +5. `Y` to `Link to existing project? [Y/n] ` +6. `opengraph-image` to `What’s the name of your existing project?` + # Quickstart -1. To get started: `yarn install` -2. To test locally: `yarn start` +1. To test locally: `yarn start` The local image preview is broken for some reason; but the service works. E.g. try `http://localhost:3000/manifold.png` -3. To deploy: push to Github - -For more info, see Contributing.md - -- note2: You may have to configure Vercel the first time: - - ``` - $ yarn start - yarn run v1.22.10 - $ cd .. && vercel dev - Vercel CLI 23.1.2 dev (beta) — https://vercel.com/feedback - ? Set up and develop “~/Code/mantic”? [Y/n] y - ? Which scope should contain your project? Mantic Markets - ? Found project “mantic/mantic”. Link to it? [Y/n] n - ? Link to different existing project? [Y/n] y - ? What’s the name of your existing project? manifold-og-image - ``` - -- note2: (Not `dev` because that's reserved for Vercel) -- note3: (Or `cd .. && vercel --prod`, I think) +2. To deploy: push to Github +- note: (Not `dev` because that's reserved for Vercel) +- note2: (Or `cd .. && vercel --prod`, I think) +For more info, see Contributing.md (Everything below is from the original repo) +# Development +- Code of interest is contained in the `api/_lib` directory, i.e. `template.ts` is the page that renders the UI. +- Edit `parseRequest(req: IncomingMessage)` in `parser.ts` to add/edit query parameters. +- Note: When testing a remote branch on vercel, the og-image previews that apps load will point to +`https://manifold-og-image.vercel.app/m.png?question=etc.`, (see relevant code in `SEO.tsx`) and not your remote branch. +You have to find your opengraph-image branch's url and replace the part before `m.png` with it. + - You can also preview the image locally, e.g. `http://localhost:3000/m.png?question=etc.` + - Every time you change the template code you'll have to change the query parameter slightly as the image will likely be cached. +- You can find your remote branch's opengraph-image url by click `Visit Preview` on Github: +![](../../../../../Desktop/Screen Shot 2022-08-01 at 2.56.42 PM.png) + + # [Open Graph Image as a Service](https://og-image.vercel.app) diff --git a/og-image/api/_lib/challenge-template.ts b/og-image/api/_lib/challenge-template.ts new file mode 100644 index 00000000..6dc43ac1 --- /dev/null +++ b/og-image/api/_lib/challenge-template.ts @@ -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 ` + + + + Generated Image + + + + + +
+ + +
+
+ ${truncatedQuestion} +
+
+
+ + +
+

${creatorName}

+ +
+
+
${'M$' + creatorAmount}
+
${'on'}
+
${creatorOutcome}
+
+
+ + +
+ VS +
+
+ + +
+

You

+ +
+ +
+

${acceptedName}

+ +
+
+
${'M$' + challengerAmount}
+
${'on'}
+
${challengerOutcome}
+
+
+
+ +
+
+ +
+ + + +` +} diff --git a/og-image/api/_lib/parser.ts b/og-image/api/_lib/parser.ts index b8163719..6d5c9b3d 100644 --- a/og-image/api/_lib/parser.ts +++ b/og-image/api/_lib/parser.ts @@ -16,10 +16,19 @@ export function parseRequest(req: IncomingMessage) { // Attributes for Manifold card: question, probability, + numericValue, metadata, creatorName, creatorUsername, creatorAvatarUrl, + + // Challenge attributes: + challengerAmount, + challengerOutcome, + creatorAmount, + creatorOutcome, + acceptedName, + acceptedAvatarUrl, } = query || {} if (Array.isArray(fontSize)) { @@ -63,10 +72,17 @@ export function parseRequest(req: IncomingMessage) { question: getString(question) || 'Will you create a prediction market on Manifold?', probability: getString(probability), + numericValue: getString(numericValue) || '', metadata: getString(metadata) || 'Jan 1  •  M$ 123 pool', creatorName: getString(creatorName) || 'Manifold Markets', creatorUsername: getString(creatorUsername) || 'ManifoldMarkets', creatorAvatarUrl: getString(creatorAvatarUrl) || '', + challengerAmount: getString(challengerAmount) || '', + challengerOutcome: getString(challengerOutcome) || '', + creatorAmount: getString(creatorAmount) || '', + creatorOutcome: getString(creatorOutcome) || '', + acceptedName: getString(acceptedName) || '', + acceptedAvatarUrl: getString(acceptedAvatarUrl) || '', } parsedRequest.images = getDefaultImages(parsedRequest.images) return parsedRequest diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index a6b0336c..f59740c5 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -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) { - + diff --git a/og-image/api/_lib/types.ts b/og-image/api/_lib/types.ts index c0126a3b..ef0a8135 100644 --- a/og-image/api/_lib/types.ts +++ b/og-image/api/_lib/types.ts @@ -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 } diff --git a/og-image/api/index.ts b/og-image/api/index.ts index 467afcc9..1f1a837c 100644 --- a/og-image/api/index.ts +++ b/og-image/api/index.ts @@ -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("

Internal Error

Sorry, there was a problem

"); - console.error(e); + res.statusCode = 500 + res.setHeader('Content-Type', 'text/html') + res.end('

Internal Error

Sorry, there was a problem

') + console.error(e) } } diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index 11e24c99..08dee31e 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -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 ( @@ -71,13 +97,13 @@ export function SEO(props: { <> diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index 653368b6..332c96be 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -1,4 +1,4 @@ -import { createContext, useEffect } from 'react' +import { ReactNode, createContext, useEffect } from 'react' import { User } from 'common/user' import { onIdTokenChanged } from 'firebase/auth' import { @@ -7,7 +7,7 @@ import { getUser, setCachedReferralInfoForUser, } from 'web/lib/firebase/users' -import { deleteAuthCookies, setAuthCookies } from 'web/lib/firebase/auth' +import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth' import { createUser } from 'web/lib/firebase/api' import { randomString } from 'common/util/random' import { identifyUser, setUserProperty } from 'web/lib/service/analytics' @@ -28,20 +28,28 @@ const ensureDeviceToken = () => { return deviceToken } -export const AuthContext = createContext(null) - -export function AuthProvider({ children }: any) { - const [authUser, setAuthUser] = useStateCheckEquality(undefined) +export const AuthContext = createContext(undefined) +export function AuthProvider(props: { + children: ReactNode + serverUser?: AuthUser +}) { + const { children, serverUser } = props + const [authUser, setAuthUser] = useStateCheckEquality(serverUser) useEffect(() => { - const cachedUser = localStorage.getItem(CACHED_USER_KEY) - setAuthUser(cachedUser && JSON.parse(cachedUser)) - }, [setAuthUser]) + if (serverUser === undefined) { + const cachedUser = localStorage.getItem(CACHED_USER_KEY) + setAuthUser(cachedUser && JSON.parse(cachedUser)) + } + }, [setAuthUser, serverUser]) useEffect(() => { return onIdTokenChanged(auth, async (fbUser) => { if (fbUser) { - setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken) + setTokenCookies({ + id: await fbUser.getIdToken(), + refresh: fbUser.refreshToken, + }) let user = await getUser(fbUser.uid) if (!user) { const deviceToken = ensureDeviceToken() @@ -54,7 +62,7 @@ export function AuthProvider({ children }: any) { setCachedReferralInfoForUser(user) } else { // User logged out; reset to null - deleteAuthCookies() + deleteTokenCookies() setAuthUser(null) localStorage.removeItem(CACHED_USER_KEY) } diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index aea38c86..c0f7ff94 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -16,8 +16,7 @@ import { import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet' import { User } from 'web/lib/firebase/users' import { Bet, LimitBet } from 'common/bet' -import { APIError, placeBet } from 'web/lib/firebase/api' -import { sellShares } from 'web/lib/firebase/api' +import { APIError, placeBet, sellShares } from 'web/lib/firebase/api' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' import { @@ -351,7 +350,7 @@ function BuyPanel(props: { {user && (