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/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..05721dcf 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() 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/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-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 +

+
+
+
+ +
+
+ +
+ + + + - @@ -237,14 +299,14 @@ export function ContractSearch(props: { All {user ? 'For you' : 'Featured'} @@ -253,7 +315,7 @@ export function ContractSearch(props: { Your bets @@ -264,7 +326,7 @@ export function ContractSearch(props: { {name} @@ -280,103 +342,17 @@ export function ContractSearch(props: { memberGroupSlugs.length === 0 ? ( <>You're not following anyone, nor in any of your own groups yet. ) : ( - )} - - ) -} - -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 ( - + ) } 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}

- {outcomeType === 'FREE_RESPONSE' && + {(outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE') && (resolution ? ( )} - {outcomeType === 'FREE_RESPONSE' && ( + {(outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE') && ( {resolution ? ( @@ -324,20 +332,21 @@ export function PseudoNumericResolutionOrExpectation(props: { {resolution === 'CANCEL' ? ( ) : ( -
- {resolutionValue - ? formatLargeNumber(resolutionValue) - : formatNumericProbability( - resolutionProbability ?? 0, - contract - )} +
+ {formatLargeNumber(value)}
)} ) : ( <> -
- {formatNumericProbability(getProbability(contract), contract)} +
+ {formatLargeNumber(value)}
expected
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/copy-contract-button.tsx b/web/components/copy-contract-button.tsx index fcb3a347..8536df71 100644 --- a/web/components/copy-contract-button.tsx +++ b/web/components/copy-contract-button.tsx @@ -49,6 +49,10 @@ function duplicateContractHref(contract: Contract) { params.initValue = getMappedValue(contract)(contract.initialProbability) } + if (contract.groupLinks && contract.groupLinks.length > 0) { + params.groupId = contract.groupLinks[0].groupId + } + return ( `/create?` + Object.entries(params) 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: { - {user && group.memberIds.includes(user.id) && ( + {user && canModifyGroupContracts(group, user.id) && ( diff --git a/web/components/groups/create-group-button.tsx b/web/components/groups/create-group-button.tsx index 0685d8e4..360c4ea8 100644 --- a/web/components/groups/create-group-button.tsx +++ b/web/components/groups/create-group-button.tsx @@ -46,7 +46,7 @@ export function CreateGroupButton(props: { const newGroup = { name: groupName, memberIds: memberUsers.map((user) => user.id), - anyoneCanJoin: false, + anyoneCanJoin: true, } const result = await createGroup(newGroup).catch((e) => { const errorDetails = e.details[0] diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 2cf2d73d..91de63c6 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -1,6 +1,6 @@ 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' @@ -23,6 +23,9 @@ 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 { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' +import { ChatIcon, ChevronDownIcon } from '@heroicons/react/outline' +import { setNotificationsAsSeen } from 'web/pages/notifications' export function GroupChat(props: { messages: Comment[] @@ -44,6 +47,13 @@ export function GroupChat(props: { const router = useRouter() const isMember = user && group.memberIds.includes(user?.id) + const { width, height } = useWindowSize() + const [containerRef, setContainerRef] = useState(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 + useMemo(() => { // Group messages with createdTime within 2 minutes of each other. const tempMessages = [] @@ -70,9 +80,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,6 +92,11 @@ export function GroupChat(props: { } }, [messages, router.asPath]) + useEffect(() => { + // is mobile? + if (inputRef && width && width > 720) inputRef.focus() + }, [inputRef, width]) + function onReplyClick(comment: Comment) { setReplyToUsername(comment.userUsername) } @@ -98,18 +114,6 @@ export function GroupChat(props: { setReplyToUsername('') inputRef?.focus() } - function focusInput() { - inputRef?.focus() - } - - const { width, height } = useWindowSize() - const [containerRef, setContainerRef] = useState(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 (
@@ -140,7 +144,7 @@ export function GroupChat(props: { No messages yet. Why not{isMember ? ` ` : ' join and '} @@ -175,6 +179,117 @@ 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 ( + + {shouldShowChat && ( + + )} + + + ) +} + +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 ( +
0 && !shouldSetAsSeen + ? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500' + : 'hidden' + } + >
+ ) +} + const GroupMessage = memo(function GroupMessage_(props: { user: User | null | undefined comment: Comment 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/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 (
{formatMoney(amount)} + + + {!finishedCreating && ( @@ -199,17 +202,17 @@ function CreateManalinkForm(props: { copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : '' )} > -
- {getManalinkUrl(highlightedSlug)} -
+
{url}
{ - 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" /> + + )} diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 581dd5fa..a051faed 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,7 +27,6 @@ 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' @@ -216,7 +215,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 +293,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(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 ( <> {memberItems.map((item) => ( - !n.isSeen && - (n.isSeenOnHref === item.href || - n.isSeenOnHref === item.href.replace('/chat', '')) - ) && 'font-bold' + notifIsForThisItem(item.href) && 'font-bold' )} > - {item.name} + {item.name} ))} 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 ( + { + setUserHasSeenWelcome() + setOpen(newOpen) + }} + > +
+ {page === 0 && } + {page === 1 && } + {page === 2 && } + {page === 3 && } + + + + + + + { + setOpen(false) + setUserHasSeenWelcome() + }} + > + I got the gist, exit welcome + + + + + ) +} + +function PageIndicator(props: { page: number; totalpages: number }) { + const { page, totalpages } = props + return ( + + {[...Array(totalpages)].map((e, i) => ( +
+ ))} + + ) +} + +function Page0() { + return ( + <> + + + <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/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/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 035536b5..d628e92d 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -214,6 +214,10 @@ export function UserPage(props: { user: User; currentUser?: User }) { <Row className="gap-4"> <FollowingButton user={user} /> <FollowersButton user={user} /> + {currentUser && + ['ian', 'Austin', 'SG', 'JamesGrugett'].includes( + currentUser.username + ) && <ReferralsButton user={user} />} <GroupsButton user={user} /> </Row> 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..7772f9d2 100644 --- a/web/hooks/use-save-referral.ts +++ b/web/hooks/use-save-referral.ts @@ -18,10 +18,14 @@ export const useSaveReferral = ( referrer?: string } - const actualReferrer = referrer || options?.defaultReferrer + const referrerOrDefault = referrer || options?.defaultReferrer - 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..ad009443 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 { 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' @@ -74,51 +72,71 @@ export function useInitialQueryAndSort(options?: { } } -export function useUpdateQueryAndSort(props: { - shouldLoadFromStorage: boolean +export function useQueryAndSortParams(options?: { + defaultSort?: Sort + shouldLoadFromStorage?: boolean }) { - const { shouldLoadFromStorage } = props + const { defaultSort = DEFAULT_SORT, shouldLoadFromStorage = true } = + options ?? {} const router = useRouter() + const { s: sort, q: query } = router.query as { + q?: string + s?: Sort + } + 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/lib/firebase/api.ts b/web/lib/firebase/api.ts index 27d6caa3..87d94dce 100644 --- a/web/lib/firebase/api.ts +++ b/web/lib/firebase/api.ts @@ -80,3 +80,7 @@ export function claimManalink(params: any) { export function createGroup(params: any) { return call(getFunctionUrl('creategroup'), 'POST', params) } + +export function getCurrentUser(params: any) { + return call(getFunctionUrl('getcurrentuser'), 'GET', params) +} diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index 3093f764..5775a2bb 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -81,6 +81,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 +92,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/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/users.ts b/web/lib/firebase/users.ts index 4f618586..5e00affe 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -96,22 +96,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/_app.tsx b/web/pages/_app.tsx index 52316eb0..14dd6cf0 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> <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/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 2d45e831..ea42b38a 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -102,7 +102,7 @@ export default function ContractSearchFirestore(props: { > <option value="newest">Newest</option> <option value="oldest">Oldest</option> - <option value="score">Most popular</option> + <option value="score">Trending</option> <option value="most-traded">Most traded</option> <option value="24-hour-vol">24h volume</option> <option value="close-date">Closing soon</option> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 84ac82da..642cbaec 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -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' @@ -122,7 +122,7 @@ export function NewContract(props: { useEffect(() => { if (groupId && creator) getGroup(groupId).then((group) => { - if (group && group.memberIds.includes(creator.id)) { + if (group && canModifyGroupContracts(group, creator.id)) { setSelectedGroup(group) setShowGroupSelector(false) } @@ -239,10 +239,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) @@ -477,6 +473,8 @@ export function NewContract(props: { {isSubmitting ? 'Creating...' : 'Create question'} </button> </Row> + + <Spacer h={6} /> </div> ) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index dd712a36..642a2afd 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, 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..c7457f27 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, { defaultReferrer: creator?.username }) +} diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 0f91d70c..be3015ee 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' @@ -16,12 +21,10 @@ 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('/') @@ -64,8 +67,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/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..b7469e9f 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -165,7 +165,6 @@ export const sendWelcomeEmail = async ( ) } -// TODO: use manalinks to give out M$500 export const sendOneWeekBonusEmail = async ( user: User, privateUser: PrivateUser @@ -185,12 +184,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 ', 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/index.ts b/functions/src/index.ts index 239806de..76e54f1c 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,7 @@ 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' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -66,6 +86,7 @@ const resolveMarketFunction = toCloudFunction(resolvemarket) const unsubscribeFunction = toCloudFunction(unsubscribe) const stripeWebhookFunction = toCloudFunction(stripewebhook) const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) +const getCurrentUserFunction = toCloudFunction(getcurrentuser) export { healthFunction as health, @@ -86,4 +107,5 @@ export { unsubscribeFunction as unsubscribe, stripeWebhookFunction as stripewebhook, createCheckoutSessionFunction as createcheckoutsession, + getCurrentUserFunction as getcurrentuser, } 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-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..0064b69f 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -25,6 +25,7 @@ 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' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -62,6 +63,7 @@ addJsonEndpointRoute('/creategroup', creategroup) addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) +addJsonEndpointRoute('/getcurrentuser', getcurrentuser) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) app.listen(PORT) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 25cd00ad..18349597 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -157,9 +157,7 @@ export function BetsList(props: { (c) => contractsMetrics[c.id].netPayout ) - const totalPortfolio = currentNetInvestment + user.balance - - const totalPnl = totalPortfolio - user.totalDeposits + const totalPnl = user.profitCached.allTime const totalProfitPercent = (totalPnl / user.totalDeposits) * 100 const investedProfitPercent = ((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100 @@ -354,7 +352,7 @@ function ContractBets(props: { )} diff --git a/web/components/button.tsx b/web/components/button.tsx index 8877c023..7eeca3d2 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -39,8 +39,10 @@ export function Button(props: { color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', - color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200', - color === 'gray-white' && 'bg-white text-gray-500 hover:bg-gray-200', + color === 'gray' && + 'bg-greyscale-1 text-greyscale-7 hover:bg-greyscale-2', + color === 'gray-white' && + 'text-greyscale-6 hover:bg-greyscale-2 bg-white', className )} disabled={disabled} diff --git a/web/components/buttons/pill-button.tsx b/web/components/buttons/pill-button.tsx index 5b4962b7..8e47c94e 100644 --- a/web/components/buttons/pill-button.tsx +++ b/web/components/buttons/pill-button.tsx @@ -15,8 +15,8 @@ export function PillButton(props: { className={clsx( 'cursor-pointer select-none whitespace-nowrap rounded-full', selected - ? ['text-white', color ?? 'bg-gray-700'] - : 'bg-gray-100 hover:bg-gray-200', + ? ['text-white', color ?? 'bg-greyscale-6'] + : 'bg-greyscale-2 hover:bg-greyscale-3', big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm' )} onClick={onSelect} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index c7660138..c1e63175 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' 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( querySortOptions?.defaultFilter ?? 'open' ) - const pillsEnabled = !additionalFilter + const pillsEnabled = !additionalFilter && !query const [pillFilter, setPillFilter] = useState(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 ( - +