From 0568322c82fe0a39ee8b6d6f7c002a0262d59a1c Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Wed, 31 Aug 2022 22:13:26 -0700 Subject: [PATCH 01/82] Dramatically improve server auth stuff (#826) --- common/envs/constants.ts | 5 + web/components/auth-context.tsx | 22 ++- web/lib/firebase/auth.ts | 74 ---------- web/lib/firebase/server-auth.ts | 198 ++++++++------------------ web/pages/[username]/index.tsx | 2 +- web/pages/create.tsx | 2 +- web/pages/experimental/home/index.tsx | 2 +- web/pages/home.tsx | 2 +- web/pages/links.tsx | 2 +- web/pages/profile.tsx | 2 +- 10 files changed, 84 insertions(+), 227 deletions(-) delete mode 100644 web/lib/firebase/auth.ts diff --git a/common/envs/constants.ts b/common/envs/constants.ts index 89d040e8..ba460d58 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -34,6 +34,11 @@ export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE' +export const AUTH_COOKIE_NAME = `FBUSER_${PROJECT_ID.toUpperCase().replace( + /-/g, + '_' +)}` + // Manifold's domain or any subdomains thereof export const CORS_ORIGIN_MANIFOLD = new RegExp( '^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$' diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index 7347d039..0e9fbd0e 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -8,17 +8,20 @@ import { getUserAndPrivateUser, setCachedReferralInfoForUser, } from 'web/lib/firebase/users' -import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth' import { createUser } from 'web/lib/firebase/api' import { randomString } from 'common/util/random' import { identifyUser, setUserProperty } from 'web/lib/service/analytics' import { useStateCheckEquality } from 'web/hooks/use-state-check-equality' +import { AUTH_COOKIE_NAME } from 'common/envs/constants' +import { setCookie } from 'web/lib/util/cookie' // Either we haven't looked up the logged in user yet (undefined), or we know // the user is not logged in (null), or we know the user is logged in. type AuthUser = undefined | null | UserAndPrivateUser +const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10 const CACHED_USER_KEY = 'CACHED_USER_KEY_V2' + // Proxy localStorage in case it's not available (eg in incognito iframe) const localStorage = typeof window !== 'undefined' @@ -38,6 +41,16 @@ const ensureDeviceToken = () => { return deviceToken } +export const setUserCookie = (cookie: string | undefined) => { + const data = setCookie(AUTH_COOKIE_NAME, cookie ?? '', [ + ['path', '/'], + ['max-age', (cookie === undefined ? 0 : TEN_YEARS_SECS).toString()], + ['samesite', 'lax'], + ['secure'], + ]) + document.cookie = data +} + export const AuthContext = createContext(undefined) export function AuthProvider(props: { @@ -59,10 +72,7 @@ export function AuthProvider(props: { auth, async (fbUser) => { if (fbUser) { - setTokenCookies({ - id: await fbUser.getIdToken(), - refresh: fbUser.refreshToken, - }) + setUserCookie(JSON.stringify(fbUser.toJSON())) let current = await getUserAndPrivateUser(fbUser.uid) if (!current.user || !current.privateUser) { const deviceToken = ensureDeviceToken() @@ -75,7 +85,7 @@ export function AuthProvider(props: { setCachedReferralInfoForUser(current.user) } else { // User logged out; reset to null - deleteTokenCookies() + setUserCookie(undefined) setAuthUser(null) localStorage.removeItem(CACHED_USER_KEY) } diff --git a/web/lib/firebase/auth.ts b/web/lib/firebase/auth.ts deleted file mode 100644 index 5363aa08..00000000 --- a/web/lib/firebase/auth.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { PROJECT_ID } from 'common/envs/constants' -import { setCookie, getCookies } from '../util/cookie' -import { IncomingMessage, ServerResponse } from 'http' - -const ONE_HOUR_SECS = 60 * 60 -const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10 -const TOKEN_KINDS = ['refresh', 'id', 'custom'] as const -const TOKEN_AGES = { - id: ONE_HOUR_SECS, - refresh: TEN_YEARS_SECS, - custom: ONE_HOUR_SECS, -} as const -export type TokenKind = typeof TOKEN_KINDS[number] - -const getAuthCookieName = (kind: TokenKind) => { - const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replace(/-/g, '_') - return `FIREBASE_TOKEN_${suffix}` -} - -const COOKIE_NAMES = Object.fromEntries( - TOKEN_KINDS.map((k) => [k, getAuthCookieName(k)]) -) as Record - -const getCookieDataIsomorphic = (req?: IncomingMessage) => { - if (req != null) { - return req.headers.cookie ?? '' - } else if (document != null) { - return document.cookie - } else { - throw new Error( - 'Neither request nor document is available; no way to get cookies.' - ) - } -} - -const setCookieDataIsomorphic = (cookies: string[], res?: ServerResponse) => { - if (res != null) { - res.setHeader('Set-Cookie', cookies) - } else if (document != null) { - for (const ck of cookies) { - document.cookie = ck - } - } else { - throw new Error( - 'Neither response nor document is available; no way to set cookies.' - ) - } -} - -export const getTokensFromCookies = (req?: IncomingMessage) => { - const cookies = getCookies(getCookieDataIsomorphic(req)) - return Object.fromEntries( - TOKEN_KINDS.map((k) => [k, cookies[COOKIE_NAMES[k]]]) - ) as Partial> -} - -export const setTokenCookies = ( - cookies: Partial>, - res?: ServerResponse -) => { - const data = TOKEN_KINDS.filter((k) => k in cookies).map((k) => { - const maxAge = cookies[k] ? TOKEN_AGES[k as TokenKind] : 0 - return setCookie(COOKIE_NAMES[k], cookies[k] ?? '', [ - ['path', '/'], - ['max-age', maxAge.toString()], - ['samesite', 'lax'], - ['secure'], - ]) - }) - setCookieDataIsomorphic(data, res) -} - -export const deleteTokenCookies = (res?: ServerResponse) => - setTokenCookies({ id: undefined, refresh: undefined, custom: undefined }, res) diff --git a/web/lib/firebase/server-auth.ts b/web/lib/firebase/server-auth.ts index ff6592e2..989767d0 100644 --- a/web/lib/firebase/server-auth.ts +++ b/web/lib/firebase/server-auth.ts @@ -1,165 +1,81 @@ -import fetch from 'node-fetch' import { IncomingMessage, ServerResponse } from 'http' -import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants' -import { getFunctionUrl } from 'common/api' -import { UserCredential } from 'firebase/auth' -import { - getTokensFromCookies, - setTokenCookies, - deleteTokenCookies, -} from './auth' +import { Auth as FirebaseAuth, User as FirebaseUser } from 'firebase/auth' +import { AUTH_COOKIE_NAME } from 'common/envs/constants' +import { getCookies } from 'web/lib/util/cookie' import { GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult, } from 'next' -// server firebase SDK -import * as admin from 'firebase-admin' - // client firebase SDK import { app as clientApp } from './init' -import { getAuth, signInWithCustomToken } from 'firebase/auth' - -const ensureApp = async () => { - // Note: firebase-admin can only be imported from a server context, - // because it relies on Node standard library dependencies. - if (admin.apps.length === 0) { - // never initialize twice - return admin.initializeApp({ projectId: PROJECT_ID }) - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return admin.apps[0]! -} - -const requestFirebaseIdToken = async (refreshToken: string) => { - // See https://firebase.google.com/docs/reference/rest/auth/#section-refresh-token - const refreshUrl = new URL('https://securetoken.googleapis.com/v1/token') - refreshUrl.searchParams.append('key', FIREBASE_CONFIG.apiKey) - const result = await fetch(refreshUrl.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - }), - }) - if (!result.ok) { - throw new Error(`Could not refresh ID token: ${await result.text()}`) - } - return (await result.json()) as { id_token: string; refresh_token: string } -} - -const requestManifoldCustomToken = async (idToken: string) => { - const functionUrl = getFunctionUrl('getcustomtoken') - const result = await fetch(functionUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${idToken}`, - }, - }) - if (!result.ok) { - throw new Error(`Could not get custom token: ${await result.text()}`) - } - return (await result.json()) as { token: string } -} +import { getAuth, updateCurrentUser } from 'firebase/auth' type RequestContext = { req: IncomingMessage res: ServerResponse } -const authAndRefreshTokens = async (ctx: RequestContext) => { - const adminAuth = (await ensureApp()).auth() - const clientAuth = getAuth(clientApp) - console.debug('Initialized Firebase auth libraries.') +// The Firebase SDK doesn't really support persisting the logged-in state between +// devices, or anything like that. To get it from the client to the server: +// +// 1. We pack up the user by calling (the undocumented) User.toJSON(). This is the +// same way the Firebase SDK saves it to disk, so it's gonna have the right stuff. +// +// 2. We put it into a cookie and read the cookie out here. +// +// 3. We use the Firebase "persistence manager" to write the cookie value into the persistent +// store on the server (an in-memory store), just as if the SDK had saved the user itself. +// +// 4. We ask the persistence manager for the current user, which reads what we just wrote, +// and creates a real puffed-up internal user object from the serialized user. +// +// 5. We set that user to be the current Firebase user in the SDK. +// +// 6. We ask for the ID token, which will refresh it if necessary (i.e. if this cookie +// is from an old browser session), so that we know the SDK is prepared to do real +// Firebase queries. +// +// This strategy should be robust, since it's repurposing Firebase's internal persistence +// machinery, but the details may eventually need updating for new versions of the SDK. +// +// References: +// Persistence manager: https://github.com/firebase/firebase-js-sdk/blob/39f4635ebc07316661324145f1b8c27f9bd7aedb/packages/auth/src/core/persistence/persistence_user_manager.ts#L64 +// Token manager: https://github.com/firebase/firebase-js-sdk/blob/39f4635ebc07316661324145f1b8c27f9bd7aedb/packages/auth/src/core/user/token_manager.ts#L76 - let { id, refresh, custom } = getTokensFromCookies(ctx.req) - - // step 0: if you have no refresh token you are logged out - if (refresh == null) { - console.debug('User is unauthenticated.') - return null - } - - console.debug('User may be authenticated; checking cookies.') - - // step 1: given a valid refresh token, ensure a valid ID token - if (id != null) { - // if they have an ID token, throw it out if it's invalid/expired - try { - await adminAuth.verifyIdToken(id) - console.debug('Verified ID token.') - } catch { - id = undefined - console.debug('Invalid existing ID token.') +interface FirebaseAuthInternal extends FirebaseAuth { + persistenceManager: { + fullUserKey: string + getCurrentUser: () => Promise + persistence: { + _set: (k: string, obj: Record) => Promise } } - if (id == null) { - // ask for a new one from google using the refresh token - try { - const resp = await requestFirebaseIdToken(refresh) - console.debug('Obtained fresh ID token from Firebase.') - id = resp.id_token - refresh = resp.refresh_token - } catch (e) { - // big unexpected problem -- functionally, they are not logged in - console.error(e) - return null - } - } - - // step 2: given a valid ID token, ensure a valid custom token, and sign in - // to the client SDK with the custom token - if (custom != null) { - // sign in with this token, or throw it out if it's invalid/expired - try { - const creds = await signInWithCustomToken(clientAuth, custom) - console.debug('Signed in with custom token.') - return { creds, id, refresh, custom } - } catch { - custom = undefined - console.debug('Invalid existing custom token.') - } - } - if (custom == null) { - // ask for a new one from our cloud functions using the ID token, then sign in - try { - const resp = await requestManifoldCustomToken(id) - console.debug('Obtained fresh custom token from backend.') - custom = resp.token - const creds = await signInWithCustomToken(clientAuth, custom) - console.debug('Signed in with custom token.') - return { creds, id, refresh, custom } - } catch (e) { - // big unexpected problem -- functionally, they are not logged in - console.error(e) - return null - } - } - return null } export const authenticateOnServer = async (ctx: RequestContext) => { - console.debug('Server authentication sequence starting.') - const tokens = await authAndRefreshTokens(ctx) - console.debug('Finished checking and refreshing tokens.') - const creds = tokens?.creds - try { - if (tokens == null) { - deleteTokenCookies(ctx.res) - console.debug('Not logged in; cleared token cookies.') - } else { - setTokenCookies(tokens, ctx.res) - console.debug('Logged in; set current token cookies.') - } - } catch (e) { - // definitely not supposed to happen, but let's be maximally robust - console.error(e) + const user = getCookies(ctx.req.headers.cookie ?? '')[AUTH_COOKIE_NAME] + if (user == null) { + console.debug('User is unauthenticated.') + return null + } + try { + const deserializedUser = JSON.parse(user) + const clientAuth = getAuth(clientApp) as FirebaseAuthInternal + const persistenceManager = clientAuth.persistenceManager + const persistence = persistenceManager.persistence + await persistence._set(persistenceManager.fullUserKey, deserializedUser) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const fbUser = (await persistenceManager.getCurrentUser())! + await fbUser.getIdToken() // forces a refresh if necessary + await updateCurrentUser(clientAuth, fbUser) + console.debug('Signed in with user from cookie.') + return fbUser + } catch (e) { + console.error(e) + return null } - return creds ?? null } // note that we might want to define these types more generically if we want better @@ -167,7 +83,7 @@ export const authenticateOnServer = async (ctx: RequestContext) => { type GetServerSidePropsAuthed

= ( context: GetServerSidePropsContext, - creds: UserCredential + creds: FirebaseUser ) => Promise> export const redirectIfLoggedIn =

( diff --git a/web/pages/[username]/index.tsx b/web/pages/[username]/index.tsx index bf6e8442..9c8adc39 100644 --- a/web/pages/[username]/index.tsx +++ b/web/pages/[username]/index.tsx @@ -17,7 +17,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) const username = ctx.params!.username as string // eslint-disable-line @typescript-eslint/no-non-null-assertion const [auth, user] = (await Promise.all([ - creds != null ? getUserAndPrivateUser(creds.user.uid) : null, + creds != null ? getUserAndPrivateUser(creds.uid) : null, getUserByUsername(username), ])) as [UserAndPrivateUser | null, User | null] return { props: { auth, user } } diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 26709417..8ea76cef 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -36,7 +36,7 @@ import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-an import { MINUTE_MS } from 'common/util/time' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { - return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } + return { props: { auth: await getUserAndPrivateUser(creds.uid) } } }) type NewQuestionParams = { diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index ae45d6ac..7adc9ef1 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -28,7 +28,7 @@ import { Row } from 'web/components/layout/row' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) - const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null + const auth = creds ? await getUserAndPrivateUser(creds.uid) : null return { props: { auth } } } diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 65161398..ff4854d7 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -15,7 +15,7 @@ import { usePrefetch } from 'web/hooks/use-prefetch' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) - const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null + const auth = creds ? await getUserAndPrivateUser(creds.uid) : null return { props: { auth } } } diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 4c4a0be1..96ccab48 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -31,7 +31,7 @@ import { UserLink } from 'web/components/user-link' const LINKS_PER_PAGE = 24 export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { - return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } + return { props: { auth: await getUserAndPrivateUser(creds.uid) } } }) export function getManalinkUrl(slug: string) { diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index ca1f3489..240fe8fa 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -23,7 +23,7 @@ import Textarea from 'react-expanding-textarea' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { - return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } + return { props: { auth: await getUserAndPrivateUser(creds.uid) } } }) function EditUserField(props: { From fec4e19c1d3816693a38bd1f24f000643115d0aa Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 1 Sep 2022 07:01:02 -0600 Subject: [PATCH 02/82] Selectively force long polling for ios only --- web/lib/firebase/init.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index b9c96a9b..44bc3a2a 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -10,9 +10,28 @@ import { connectFunctionsEmulator, getFunctions } from 'firebase/functions' // Initialize Firebase export const app = getApps().length ? getApp() : initializeApp(FIREBASE_CONFIG) -export const db = initializeFirestore(app, { - experimentalForceLongPolling: true, -}) +function iOS() { + if (typeof navigator === 'undefined') { + // we're on the server, do whatever + return false + } + return ( + [ + 'iPad Simulator', + 'iPhone Simulator', + 'iPod Simulator', + 'iPad', + 'iPhone', + 'iPod', + ].includes(navigator.platform) || + // iPad on iOS 13 detection + (navigator.userAgent.includes('Mac') && 'ontouchend' in document) + ) +} +// Necessary for ios, see: https://github.com/firebase/firebase-js-sdk/issues/6118 +const opts = iOS() ? { experimentalForceLongPolling: true } : {} +export const db = initializeFirestore(app, opts) + export const functions = getFunctions() export const storage = getStorage() From a8d7e91a022b64f8d50d54963889734ceb9201e4 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 1 Sep 2022 07:01:49 -0600 Subject: [PATCH 03/82] Clean comments --- web/lib/firebase/init.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index 44bc3a2a..6740f8c6 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -12,7 +12,7 @@ export const app = getApps().length ? getApp() : initializeApp(FIREBASE_CONFIG) function iOS() { if (typeof navigator === 'undefined') { - // we're on the server, do whatever + // We're on the server, proceed normally return false } return ( @@ -28,7 +28,7 @@ function iOS() { (navigator.userAgent.includes('Mac') && 'ontouchend' in document) ) } -// Necessary for ios, see: https://github.com/firebase/firebase-js-sdk/issues/6118 +// Long polling is necessary for ios, see: https://github.com/firebase/firebase-js-sdk/issues/6118 const opts = iOS() ? { experimentalForceLongPolling: true } : {} export const db = initializeFirestore(app, opts) From 5dec6b4a22d0f97800499ab075a20bd6fbc2adb3 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 1 Sep 2022 07:23:43 -0600 Subject: [PATCH 04/82] Medium includes 10 bettors --- web/components/contract/contract-details.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 7226aace..c61a0fd1 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -296,9 +296,9 @@ export function ExtraMobileContractDetails(props: { const uniqueBettors = uniqueBettorCount ?? 0 const { resolvedDate } = contractMetrics(contract) const volumeTranslation = - volume > 800 || uniqueBettors > 20 + volume > 800 || uniqueBettors >= 20 ? 'High' - : volume > 300 || uniqueBettors > 10 + : volume > 300 || uniqueBettors >= 10 ? 'Medium' : 'Low' From a7c8b8aec4d008997d7f14500111f96aaa6a0538 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 1 Sep 2022 07:34:02 -0600 Subject: [PATCH 05/82] Hide bet panel when signed out --- web/components/bet-panel.tsx | 47 ++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 913216e9..d596dd46 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -67,27 +67,32 @@ export function BetPanel(props: { className )} > - -

Amount
+ + Amount + (balance: {formatMoney(user?.balance ?? 0)}) + -
Bet amount
+ + Bet Amount + + (balance: {formatMoney(user?.balance ?? 0)}) + + {' '} -
Amount
+ + Amount + + (balance: {formatMoney(user?.balance ?? 0)}) + + )} -
- Max amount* -
+ + + Max amount* + + + (balance: {formatMoney(user?.balance ?? 0)}) + + Date: Thu, 1 Sep 2022 08:29:56 -0600 Subject: [PATCH 07/82] Show group based on most recent creator added group --- web/components/contract/contract-details.tsx | 22 ++++++------------- .../groups/contract-groups-list.tsx | 7 +++--- web/lib/firebase/groups.ts | 14 ++++++++++++ 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index c61a0fd1..7dbfc809 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -27,7 +27,7 @@ import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' import { ContractGroupsList } from 'web/components/groups/contract-groups-list' import { SiteLink } from 'web/components/site-link' -import { groupPath } from 'web/lib/firebase/groups' +import { getGroupLinkToDisplay, groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' import { contractMetrics } from 'common/contract-details' import { User } from 'common/user' @@ -52,10 +52,10 @@ export function MiscDetails(props: { isResolved, createdTime, resolutionTime, - groupLinks, } = contract const isNew = createdTime > Date.now() - DAY_MS && !isResolved + const groupToDisplay = getGroupLinkToDisplay(contract) return ( @@ -83,12 +83,12 @@ export function MiscDetails(props: { )} - {!hideGroupLink && groupLinks && groupLinks.length > 0 && ( + {!hideGroupLink && groupToDisplay && ( - {groupLinks[0].name} + {groupToDisplay.name} )} @@ -148,19 +148,15 @@ export function ContractDetails(props: { creatorName, creatorUsername, creatorId, - groupLinks, creatorAvatarUrl, resolutionTime, } = contract const { volumeLabel, resolvedDate } = contractMetrics(contract) - - const groupToDisplay = - groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null const user = useUser() const [open, setOpen] = useState(false) const { width } = useWindowSize() const isMobile = (width ?? 0) < 600 - + const groupToDisplay = getGroupLinkToDisplay(contract) const groupInfo = groupToDisplay ? ( - + diff --git a/web/components/groups/contract-groups-list.tsx b/web/components/groups/contract-groups-list.tsx index 79f2390f..7bbcfa7c 100644 --- a/web/components/groups/contract-groups-list.tsx +++ b/web/components/groups/contract-groups-list.tsx @@ -13,15 +13,14 @@ import { import { User } from 'common/user' import { Contract } from 'common/contract' import { SiteLink } from 'web/components/site-link' -import { GroupLink } from 'common/group' import { useGroupsWithContract } from 'web/hooks/use-group' export function ContractGroupsList(props: { - groupLinks: GroupLink[] contract: Contract user: User | null | undefined }) { - const { groupLinks, user, contract } = props + const { user, contract } = props + const { groupLinks } = contract const groups = useGroupsWithContract(contract) return ( @@ -35,7 +34,7 @@ export function ContractGroupsList(props: { options={{ showSelector: true, showLabel: false, - ignoreGroupIds: groupLinks.map((g) => g.groupId), + ignoreGroupIds: groupLinks?.map((g) => g.groupId), }} setSelectedGroup={(group) => group && addContractToGroup(group, contract, user.id) diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 28515a35..4d22e0ee 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -208,3 +208,17 @@ export function canModifyGroupContracts(group: Group, userId: string) { group.anyoneCanJoin ) } + +export function getGroupLinkToDisplay(contract: Contract) { + const { groupLinks } = contract + const sortedGroupLinks = groupLinks?.sort( + (a, b) => b.createdTime - a.createdTime + ) + const groupCreatorAdded = sortedGroupLinks?.find( + (g) => g.userId === contract.creatorId + ) + const groupToDisplay = groupCreatorAdded + ? groupCreatorAdded + : sortedGroupLinks?.[0] ?? null + return groupToDisplay +} From 0823414360b4e196396a0471eac71187e7c50e5d Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 1 Sep 2022 08:52:49 -0600 Subject: [PATCH 08/82] Adjust group name padding on mobile --- web/components/contract/contract-details.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 7dbfc809..a2432397 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -160,7 +160,7 @@ export function ContractDetails(props: { const groupInfo = groupToDisplay ? ( From fecf976ab965db877495fe98865a39ea794cdb63 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 1 Sep 2022 09:11:14 -0600 Subject: [PATCH 09/82] Show all group contracts if less than 5 open --- web/pages/group/[...slugs]/index.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 4b391b36..c9581be5 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -62,7 +62,11 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const contracts = (group && (await listContractsByGroupSlug(group.slug))) ?? [] - + const now = Date.now() + const suggestedFilter = + contracts.filter((c) => (c.closeTime ?? 0) > now).length < 5 + ? 'all' + : 'open' const aboutPost = group && group.aboutPostId != null && (await getPost(group.aboutPostId)) const bets = await Promise.all( @@ -92,6 +96,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { topCreators, messages, aboutPost, + suggestedFilter, }, revalidate: 60, // regenerate after a minute @@ -131,6 +136,7 @@ export default function GroupPage(props: { topCreators: User[] messages: GroupComment[] aboutPost: Post + suggestedFilter: 'open' | 'all' }) { props = usePropz(props, getStaticPropz) ?? { group: null, @@ -141,6 +147,7 @@ export default function GroupPage(props: { creatorScores: {}, topCreators: [], messages: [], + suggestedFilter: 'open', } const { creator, @@ -149,6 +156,7 @@ export default function GroupPage(props: { topTraders, creatorScores, topCreators, + suggestedFilter, } = props const router = useRouter() @@ -210,7 +218,7 @@ export default function GroupPage(props: { ) From 8922b370cc2e562e796ae3c58a2eb5e7f7609af1 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 1 Sep 2022 10:02:41 -0600 Subject: [PATCH 10/82] Show challenge on desktop, simplify modal --- .../challenges/create-challenge-modal.tsx | 111 +++++++----------- .../contract/extra-contract-actions-row.tsx | 28 ++++- web/components/contract/share-modal.tsx | 11 +- 3 files changed, 74 insertions(+), 76 deletions(-) diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx index 6f91a6d4..72a8fd7b 100644 --- a/web/components/challenges/create-challenge-modal.tsx +++ b/web/components/challenges/create-challenge-modal.tsx @@ -18,7 +18,6 @@ import { NoLabel, YesLabel } from '../outcome-label' import { QRCode } from '../qr-code' import { copyToClipboard } from 'web/lib/util/copy' import { AmountInput } from '../amount-input' -import { getProbability } from 'common/calculate' import { createMarket } from 'web/lib/firebase/api' import { removeUndefinedProps } from 'common/util/object' import { FIXED_ANTE } from 'common/economy' @@ -26,6 +25,7 @@ import Textarea from 'react-expanding-textarea' import { useTextEditor } from 'web/components/editor' import { LoadingIndicator } from 'web/components/loading-indicator' import { track } from 'web/lib/service/analytics' +import { useWindowSize } from 'web/hooks/use-window-size' type challengeInfo = { amount: number @@ -110,8 +110,9 @@ function CreateChallengeForm(props: { const [isCreating, setIsCreating] = useState(false) const [finishedCreating, setFinishedCreating] = useState(false) const [error, setError] = useState('') - const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false) const defaultExpire = 'week' + const { width } = useWindowSize() + const isMobile = (width ?? 0) < 768 const [challengeInfo, setChallengeInfo] = useState({ expiresTime: dayjs().add(2, defaultExpire).valueOf(), @@ -147,7 +148,7 @@ function CreateChallengeForm(props: { setFinishedCreating(true) }} > - + <Title className="!mt-2 hidden sm:block" text="Challenge bet " /> <div className="mb-8"> Challenge a friend to bet on{' '} @@ -157,7 +158,7 @@ function CreateChallengeForm(props: { <Textarea placeholder="e.g. Will a Democrat be the next president?" className="input input-bordered mt-1 w-full resize-none" - autoFocus={true} + autoFocus={!isMobile} maxLength={MAX_QUESTION_LENGTH} value={challengeInfo.question} onChange={(e) => @@ -170,89 +171,59 @@ function CreateChallengeForm(props: { )} </div> - <div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2"> - <div>You'll bet:</div> - <Row - className={ - 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' - } - > - <AmountInput - amount={challengeInfo.amount || undefined} - onChange={(newAmount) => - setChallengeInfo((m: challengeInfo) => { - return { - ...m, - amount: newAmount ?? 0, - acceptorAmount: editingAcceptorAmount - ? m.acceptorAmount - : newAmount ?? 0, - } - }) - } - error={undefined} - label={'M$'} - inputClassName="w-24" - /> - <span className={''}>on</span> - {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} - </Row> - <Row className={'mt-3 max-w-xs justify-end'}> - <Button - color={'gray-white'} - onClick={() => - setChallengeInfo((m: challengeInfo) => { - return { - ...m, - outcome: m.outcome === 'YES' ? 'NO' : 'YES', - } - }) + <Col className="mt-2 flex-wrap justify-center gap-x-5 gap-y-0 sm:gap-y-2"> + <Col> + <div>You'll bet:</div> + <Row + className={ + 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' } > - <SwitchVerticalIcon className={'h-6 w-6'} /> - </Button> - </Row> - <Row className={'items-center'}>If they bet:</Row> - <Row className={'max-w-xs items-center justify-between gap-4 pr-3'}> - <div className={'w-32 sm:mr-1'}> <AmountInput - amount={challengeInfo.acceptorAmount || undefined} - onChange={(newAmount) => { - setEditingAcceptorAmount(true) - + amount={challengeInfo.amount || undefined} + onChange={(newAmount) => setChallengeInfo((m: challengeInfo) => { return { ...m, + amount: newAmount ?? 0, acceptorAmount: newAmount ?? 0, } }) - }} + } error={undefined} label={'M$'} inputClassName="w-24" /> + <span className={''}>on</span> + {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} + </Row> + <Row className={'max-w-xs justify-end'}> + <Button + color={'gray-white'} + onClick={() => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + outcome: m.outcome === 'YES' ? 'NO' : 'YES', + } + }) + } + > + <SwitchVerticalIcon className={'h-6 w-6'} /> + </Button> + </Row> + </Col> + <Row className={'items-center'}>If they bet:</Row> + <Row className={'max-w-xs items-center justify-between gap-4 pr-3'}> + <div className={'mt-1 w-32 sm:mr-1'}> + <span className={'ml-2 font-bold'}> + {formatMoney(challengeInfo.acceptorAmount)} + </span> </div> <span>on</span> {challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} </Row> - </div> - {contract && ( - <Button - size="2xs" - color="gray" - onClick={() => { - setEditingAcceptorAmount(true) - - const p = getProbability(contract) - const prob = challengeInfo.outcome === 'YES' ? p : 1 - p - const { amount } = challengeInfo - const acceptorAmount = Math.round(amount / prob - amount) - setChallengeInfo({ ...challengeInfo, acceptorAmount }) - }} - > - Use market odds - </Button> - )} + </Col> <div className="mt-8"> If the challenge is accepted, whoever is right will earn{' '} <span className="font-semibold"> diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index 4f362d84..2a5de1c0 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -11,14 +11,21 @@ import { FollowMarketButton } from 'web/components/follow-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button' import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' import { Col } from 'web/components/layout/col' +import { withTracking } from 'web/lib/service/analytics' +import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' +import { CHALLENGES_ENABLED } from 'common/lib/challenge' export function ExtraContractActionsRow(props: { contract: Contract user: User | undefined | null }) { const { user, contract } = props - + const { outcomeType, resolution } = contract const [isShareOpen, setShareOpen] = useState(false) + const [openCreateChallengeModal, setOpenCreateChallengeModal] = + useState(false) + const showChallenge = + user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED return ( <Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}> @@ -45,6 +52,25 @@ export function ExtraContractActionsRow(props: { user={user} /> </Button> + {showChallenge && ( + <Button + size="lg" + color="gray-white" + className={'flex hidden max-w-xs self-center sm:inline-block'} + onClick={withTracking( + () => setOpenCreateChallengeModal(true), + 'click challenge button' + )} + > + <span>⚔️ Challenge</span> + <CreateChallengeModal + isOpen={openCreateChallengeModal} + setOpen={setOpenCreateChallengeModal} + user={user} + contract={contract} + /> + </Button> + )} <FollowMarketButton contract={contract} user={user} /> {user?.id !== contract.creatorId && ( diff --git a/web/components/contract/share-modal.tsx b/web/components/contract/share-modal.tsx index 5bae101d..ff3f41ae 100644 --- a/web/components/contract/share-modal.tsx +++ b/web/components/contract/share-modal.tsx @@ -45,7 +45,7 @@ export function ShareModal(props: { return ( <Modal open={isOpen} setOpen={setOpen} size="md"> - <Col className="gap-4 rounded bg-white p-4"> + <Col className="gap-2.5 rounded bg-white p-4 sm:gap-4"> <Title className="!mt-0 !mb-2" text="Share this market" /> <p> Earn{' '} @@ -57,7 +57,7 @@ export function ShareModal(props: { <Button size="2xl" color="gradient" - className={'mb-2 flex max-w-xs self-center'} + className={'flex max-w-xs self-center'} onClick={() => { copyToClipboard(shareUrl) toast.success('Link copied!', { @@ -68,17 +68,18 @@ export function ShareModal(props: { > {linkIcon} Copy link </Button> + <Row className={'justify-center'}>or</Row> {showChallenge && ( <Button - size="lg" - color="gray-white" + size="2xl" + color="gradient" className={'mb-2 flex max-w-xs self-center'} onClick={withTracking( () => setOpenCreateChallengeModal(true), 'click challenge button' )} > - <span>⚔️ Challenge a friend</span> + <span>⚔️ Challenge</span> <CreateChallengeModal isOpen={openCreateChallengeModal} setOpen={(open) => { From 7310cf3d4a6a29e202e72e79790d62a139891643 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 10:11:08 -0600 Subject: [PATCH 11/82] fix import --- web/components/contract/extra-contract-actions-row.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index 2a5de1c0..2ae370b1 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -13,7 +13,7 @@ import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog import { Col } from 'web/components/layout/col' import { withTracking } from 'web/lib/service/analytics' import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' -import { CHALLENGES_ENABLED } from 'common/lib/challenge' +import { CHALLENGES_ENABLED } from 'common/challenge' export function ExtraContractActionsRow(props: { contract: Contract From 96be4e89925525d472d1e0f20cbc581e0ad62137 Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Thu, 1 Sep 2022 17:47:45 +0100 Subject: [PATCH 12/82] Add embedded ContractGrid to Posts (#822) * Add embedded market grids * Hacky way to set height I haven't figured out a way yet to get the height of the actual iframe's content, so I did some bad estimate for now to unblock shipping the feature, while I continue investigating. --- common/util/tiptap-iframe.ts | 10 +++++- web/components/contract/contracts-grid.tsx | 3 +- web/components/editor/market-modal.tsx | 17 +++++++--- web/components/share-embed-button.tsx | 13 ++++++-- web/pages/create-post.tsx | 2 +- web/pages/embed/grid/[...slugs]/index.tsx | 37 ++++++++++++++++++++++ 6 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 web/pages/embed/grid/[...slugs]/index.tsx diff --git a/common/util/tiptap-iframe.ts b/common/util/tiptap-iframe.ts index 5af63d2f..9e260821 100644 --- a/common/util/tiptap-iframe.ts +++ b/common/util/tiptap-iframe.ts @@ -35,7 +35,7 @@ export default Node.create<IframeOptions>({ HTMLAttributes: { class: 'iframe-wrapper' + ' ' + wrapperClasses, // Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in: - style: 'padding-bottom: 20rem;', + style: 'padding-bottom: 20rem; ', }, } }, @@ -48,6 +48,9 @@ export default Node.create<IframeOptions>({ frameborder: { default: 0, }, + height: { + default: 0, + }, allowfullscreen: { default: this.options.allowFullscreen, parseHTML: () => this.options.allowFullscreen, @@ -60,6 +63,11 @@ export default Node.create<IframeOptions>({ }, renderHTML({ HTMLAttributes }) { + this.options.HTMLAttributes.style = + this.options.HTMLAttributes.style + + ' height: ' + + HTMLAttributes.height + + ';' return [ 'div', this.options.HTMLAttributes, diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 2f804644..3a09a167 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -27,6 +27,7 @@ export function ContractsGrid(props: { } highlightOptions?: ContractHighlightOptions trackingPostfix?: string + breakpointColumns?: { [key: string]: number } }) { const { contracts, @@ -67,7 +68,7 @@ export function ContractsGrid(props: { <Col className="gap-8"> <Masonry // Show only 1 column on tailwind's md breakpoint (768px) - breakpointCols={{ default: 2, 768: 1 }} + breakpointCols={props.breakpointColumns ?? { default: 2, 768: 1 }} className="-ml-4 flex w-auto" columnClassName="pl-4 bg-clip-padding" > diff --git a/web/components/editor/market-modal.tsx b/web/components/editor/market-modal.tsx index a81953de..31c437b1 100644 --- a/web/components/editor/market-modal.tsx +++ b/web/components/editor/market-modal.tsx @@ -7,7 +7,7 @@ import { Col } from '../layout/col' import { Modal } from '../layout/modal' import { Row } from '../layout/row' import { LoadingIndicator } from '../loading-indicator' -import { embedCode } from '../share-embed-button' +import { embedContractCode, embedContractGridCode } from '../share-embed-button' import { insertContent } from './utils' export function MarketModal(props: { @@ -28,7 +28,11 @@ export function MarketModal(props: { async function doneAddingContracts() { setLoading(true) - insertContent(editor, ...contracts.map(embedCode)) + if (contracts.length == 1) { + insertContent(editor, embedContractCode(contracts[0])) + } else if (contracts.length > 1) { + insertContent(editor, embedContractGridCode(contracts)) + } setLoading(false) setOpen(false) setContracts([]) @@ -42,9 +46,14 @@ export function MarketModal(props: { {!loading && ( <Row className="grow justify-end gap-4"> - {contracts.length > 0 && ( + {contracts.length == 1 && ( <Button onClick={doneAddingContracts} color={'indigo'}> - Embed {contracts.length} question + Embed 1 question + </Button> + )} + {contracts.length > 1 && ( + <Button onClick={doneAddingContracts} color={'indigo'}> + Embed grid of {contracts.length} question {contracts.length > 1 && 's'} </Button> )} diff --git a/web/components/share-embed-button.tsx b/web/components/share-embed-button.tsx index cfbe78f0..a42ffc34 100644 --- a/web/components/share-embed-button.tsx +++ b/web/components/share-embed-button.tsx @@ -9,11 +9,18 @@ import { DOMAIN } from 'common/envs/constants' import { copyToClipboard } from 'web/lib/util/copy' import { track } from 'web/lib/service/analytics' -export function embedCode(contract: Contract) { +export function embedContractCode(contract: Contract) { const title = contract.question const src = `https://${DOMAIN}/embed${contractPath(contract)}` + return `<iframe src="${src}" title="${title}" frameborder="0"></iframe>` +} - return `<iframe width="560" height="405" src="${src}" title="${title}" frameborder="0"></iframe>` +export function embedContractGridCode(contracts: Contract[]) { + const height = (contracts.length - (contracts.length % 2)) * 100 + 'px' + const src = `http://${DOMAIN}/embed/grid/${contracts + .map((c) => c.slug) + .join('/')}` + return `<iframe height="${height}" src="${src}" title="Grid of contracts" frameborder="0"></iframe>` } export function ShareEmbedButton(props: { contract: Contract }) { @@ -26,7 +33,7 @@ export function ShareEmbedButton(props: { contract: Contract }) { as="div" className="relative z-10 flex-shrink-0" onMouseUp={() => { - copyToClipboard(embedCode(contract)) + copyToClipboard(embedContractCode(contract)) toast.success('Embed code copied!', { icon: codeIcon, }) diff --git a/web/pages/create-post.tsx b/web/pages/create-post.tsx index f88f56a5..01147cc0 100644 --- a/web/pages/create-post.tsx +++ b/web/pages/create-post.tsx @@ -41,7 +41,7 @@ export default function CreatePost() { return ( <Page> - <div className="mx-auto w-full max-w-2xl"> + <div className="mx-auto w-full max-w-3xl"> <div className="rounded-lg px-6 py-4 sm:py-0"> <Title className="!mt-0" text="Create a post" /> <form> diff --git a/web/pages/embed/grid/[...slugs]/index.tsx b/web/pages/embed/grid/[...slugs]/index.tsx new file mode 100644 index 00000000..7500665f --- /dev/null +++ b/web/pages/embed/grid/[...slugs]/index.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { Contract, getContractFromSlug } from 'web/lib/firebase/contracts' +import { ContractsGrid } from 'web/components/contract/contracts-grid' + +export async function getStaticProps(props: { params: { slugs: string[] } }) { + const { slugs } = props.params + + const contracts = (await Promise.all( + slugs.map((slug) => + getContractFromSlug(slug) != null ? getContractFromSlug(slug) : [] + ) + )) as Contract[] + + return { + props: { + contracts, + }, + revalidate: 60, // regenerate after a minute + } +} + +export async function getStaticPaths() { + return { paths: [], fallback: 'blocking' } +} + +export default function ContractGridPage(props: { contracts: Contract[] }) { + const { contracts } = props + + return ( + <> + <ContractsGrid + contracts={contracts} + breakpointColumns={{ default: 2, 650: 1 }} + /> + </> + ) +} From 1208694d2d0d8eced31d77b5c2aa0ca75443826b Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Thu, 1 Sep 2022 17:54:46 +0100 Subject: [PATCH 13/82] http to https to avoid blocked requests (#833) --- web/components/share-embed-button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/share-embed-button.tsx b/web/components/share-embed-button.tsx index a42ffc34..79c63d5a 100644 --- a/web/components/share-embed-button.tsx +++ b/web/components/share-embed-button.tsx @@ -17,7 +17,7 @@ export function embedContractCode(contract: Contract) { export function embedContractGridCode(contracts: Contract[]) { const height = (contracts.length - (contracts.length % 2)) * 100 + 'px' - const src = `http://${DOMAIN}/embed/grid/${contracts + const src = `https://${DOMAIN}/embed/grid/${contracts .map((c) => c.slug) .join('/')}` return `<iframe height="${height}" src="${src}" title="Grid of contracts" frameborder="0"></iframe>` From 8d853815d675de395a88e3e80085a2ccf68d0019 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 13:55:24 -0600 Subject: [PATCH 14/82] Show resolution on og card image (#834) * Handle resolved markets * Add in group names as hashtags --- common/contract-details.ts | 45 +++++--- og-image/api/_lib/challenge-template.ts | 84 +------------- og-image/api/_lib/parser.ts | 2 + og-image/api/_lib/template-css.ts | 81 +++++++++++++ og-image/api/_lib/template.ts | 147 +++++++++--------------- og-image/api/_lib/types.ts | 1 + 6 files changed, 172 insertions(+), 188 deletions(-) create mode 100644 og-image/api/_lib/template-css.ts diff --git a/common/contract-details.ts b/common/contract-details.ts index 02af6359..c231b1e4 100644 --- a/common/contract-details.ts +++ b/common/contract-details.ts @@ -27,10 +27,10 @@ export function contractMetrics(contract: Contract) { export function contractTextDetails(contract: Contract) { // eslint-disable-next-line @typescript-eslint/no-var-requires const dayjs = require('dayjs') - const { closeTime, tags } = contract + const { closeTime, groupLinks } = contract const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) - const hashtags = tags.map((tag) => `#${tag}`) + const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`) return ( `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + @@ -40,7 +40,7 @@ export function contractTextDetails(contract: Contract) { ).format('MMM D, h:mma')}` : '') + ` • ${volumeLabel}` + - (hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '') + (groupHashtags ? ` • ${groupHashtags.join(' ')}` : '') ) } @@ -92,6 +92,7 @@ export const getOpenGraphProps = (contract: Contract) => { creatorAvatarUrl, description, numericValue, + resolution, } } @@ -103,6 +104,7 @@ export type OgCardProps = { creatorUsername: string creatorAvatarUrl?: string numericValue?: string + resolution?: string } export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { @@ -113,22 +115,32 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { creatorOutcome, acceptorOutcome, } = challenge || {} + const { + probability, + numericValue, + resolution, + creatorAvatarUrl, + question, + metadata, + creatorUsername, + creatorName, + } = props const { userName, userAvatarUrl } = acceptances?.[0] ?? {} const probabilityParam = - props.probability === undefined + probability === undefined ? '' - : `&probability=${encodeURIComponent(props.probability ?? '')}` + : `&probability=${encodeURIComponent(probability ?? '')}` const numericValueParam = - props.numericValue === undefined + numericValue === undefined ? '' - : `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` + : `&numericValue=${encodeURIComponent(numericValue ?? '')}` const creatorAvatarUrlParam = - props.creatorAvatarUrl === undefined + creatorAvatarUrl === undefined ? '' - : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` + : `&creatorAvatarUrl=${encodeURIComponent(creatorAvatarUrl ?? '')}` const challengeUrlParams = challenge ? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` + @@ -136,16 +148,21 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { `&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}` : '' + const resolutionUrlParam = resolution + ? `&resolution=${encodeURIComponent(resolution)}` + : '' + // URL encode each of the props, then add them as query params return ( `https://manifold-og-image.vercel.app/m.png` + - `?question=${encodeURIComponent(props.question)}` + + `?question=${encodeURIComponent(question)}` + probabilityParam + numericValueParam + - `&metadata=${encodeURIComponent(props.metadata)}` + - `&creatorName=${encodeURIComponent(props.creatorName)}` + + `&metadata=${encodeURIComponent(metadata)}` + + `&creatorName=${encodeURIComponent(creatorName)}` + creatorAvatarUrlParam + - `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + - challengeUrlParams + `&creatorUsername=${encodeURIComponent(creatorUsername)}` + + challengeUrlParams + + resolutionUrlParam ) } diff --git a/og-image/api/_lib/challenge-template.ts b/og-image/api/_lib/challenge-template.ts index 6dc43ac1..647d69b6 100644 --- a/og-image/api/_lib/challenge-template.ts +++ b/og-image/api/_lib/challenge-template.ts @@ -1,85 +1,5 @@ -import { sanitizeHtml } from './sanitizer' import { ParsedRequest } from './types' - -function getCss(theme: string, fontSize: string) { - let background = 'white' - let foreground = 'black' - let radial = 'lightgray' - - if (theme === 'dark') { - background = 'black' - foreground = 'white' - radial = 'dimgray' - } - // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` - return ` - @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); - - body { - background: ${background}; - background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); - background-size: 100px 100px; - height: 100vh; - font-family: "Readex Pro", sans-serif; - } - - code { - color: #D400FF; - font-family: 'Vera'; - white-space: pre-wrap; - letter-spacing: -5px; - } - - code:before, code:after { - content: '\`'; - } - - .logo-wrapper { - display: flex; - align-items: center; - align-content: center; - justify-content: center; - justify-items: center; - } - - .logo { - margin: 0 75px; - } - - .plus { - color: #BBB; - font-family: Times New Roman, Verdana; - font-size: 100px; - } - - .spacer { - margin: 150px; - } - - .emoji { - height: 1em; - width: 1em; - margin: 0 .05em 0 .1em; - vertical-align: -0.1em; - } - - .heading { - font-family: 'Major Mono Display', monospace; - font-size: ${sanitizeHtml(fontSize)}; - font-style: normal; - color: ${foreground}; - line-height: 1.8; - } - - .font-major-mono { - font-family: "Major Mono Display", monospace; - } - - .text-primary { - color: #11b981; - } - ` -} +import { getTemplateCss } from './template-css' export function getChallengeHtml(parsedReq: ParsedRequest) { const { @@ -112,7 +32,7 @@ export function getChallengeHtml(parsedReq: ParsedRequest) { <script src="https://cdn.tailwindcss.com"></script> </head> <style> - ${getCss(theme, fontSize)} + ${getTemplateCss(theme, fontSize)} </style> <body> <div class="px-24"> diff --git a/og-image/api/_lib/parser.ts b/og-image/api/_lib/parser.ts index 6d5c9b3d..131a3cc4 100644 --- a/og-image/api/_lib/parser.ts +++ b/og-image/api/_lib/parser.ts @@ -21,6 +21,7 @@ export function parseRequest(req: IncomingMessage) { creatorName, creatorUsername, creatorAvatarUrl, + resolution, // Challenge attributes: challengerAmount, @@ -71,6 +72,7 @@ export function parseRequest(req: IncomingMessage) { question: getString(question) || 'Will you create a prediction market on Manifold?', + resolution: getString(resolution), probability: getString(probability), numericValue: getString(numericValue) || '', metadata: getString(metadata) || 'Jan 1  •  M$ 123 pool', diff --git a/og-image/api/_lib/template-css.ts b/og-image/api/_lib/template-css.ts new file mode 100644 index 00000000..f4ca6660 --- /dev/null +++ b/og-image/api/_lib/template-css.ts @@ -0,0 +1,81 @@ +import { sanitizeHtml } from './sanitizer' + +export function getTemplateCss(theme: string, fontSize: string) { + let background = 'white' + let foreground = 'black' + let radial = 'lightgray' + + if (theme === 'dark') { + background = 'black' + foreground = 'white' + radial = 'dimgray' + } + // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` + return ` + @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); + + body { + background: ${background}; + background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); + background-size: 100px 100px; + height: 100vh; + font-family: "Readex Pro", sans-serif; + } + + code { + color: #D400FF; + font-family: 'Vera'; + white-space: pre-wrap; + letter-spacing: -5px; + } + + code:before, code:after { + content: '\`'; + } + + .logo-wrapper { + display: flex; + align-items: center; + align-content: center; + justify-content: center; + justify-items: center; + } + + .logo { + margin: 0 75px; + } + + .plus { + color: #BBB; + font-family: Times New Roman, Verdana; + font-size: 100px; + } + + .spacer { + margin: 150px; + } + + .emoji { + height: 1em; + width: 1em; + margin: 0 .05em 0 .1em; + vertical-align: -0.1em; + } + + .heading { + font-family: 'Major Mono Display', monospace; + font-size: ${sanitizeHtml(fontSize)}; + font-style: normal; + color: ${foreground}; + line-height: 1.8; + } + + .font-major-mono { + font-family: "Major Mono Display", monospace; + } + + .text-primary { + color: #11b981; + } + ` +} diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index f59740c5..26f7677e 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -1,85 +1,5 @@ -import { sanitizeHtml } from './sanitizer' import { ParsedRequest } from './types' - -function getCss(theme: string, fontSize: string) { - let background = 'white' - let foreground = 'black' - let radial = 'lightgray' - - if (theme === 'dark') { - background = 'black' - foreground = 'white' - radial = 'dimgray' - } - // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` - return ` - @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); - - body { - background: ${background}; - background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); - background-size: 100px 100px; - height: 100vh; - font-family: "Readex Pro", sans-serif; - } - - code { - color: #D400FF; - font-family: 'Vera'; - white-space: pre-wrap; - letter-spacing: -5px; - } - - code:before, code:after { - content: '\`'; - } - - .logo-wrapper { - display: flex; - align-items: center; - align-content: center; - justify-content: center; - justify-items: center; - } - - .logo { - margin: 0 75px; - } - - .plus { - color: #BBB; - font-family: Times New Roman, Verdana; - font-size: 100px; - } - - .spacer { - margin: 150px; - } - - .emoji { - height: 1em; - width: 1em; - margin: 0 .05em 0 .1em; - vertical-align: -0.1em; - } - - .heading { - font-family: 'Major Mono Display', monospace; - font-size: ${sanitizeHtml(fontSize)}; - font-style: normal; - color: ${foreground}; - line-height: 1.8; - } - - .font-major-mono { - font-family: "Major Mono Display", monospace; - } - - .text-primary { - color: #11b981; - } - ` -} +import { getTemplateCss } from './template-css' export function getHtml(parsedReq: ParsedRequest) { const { @@ -92,6 +12,7 @@ export function getHtml(parsedReq: ParsedRequest) { creatorUsername, creatorAvatarUrl, numericValue, + resolution, } = parsedReq const MAX_QUESTION_CHARS = 100 const truncatedQuestion = @@ -99,6 +20,49 @@ export function getHtml(parsedReq: ParsedRequest) { ? question.slice(0, MAX_QUESTION_CHARS) + '...' : question const hideAvatar = creatorAvatarUrl ? '' : 'hidden' + + let resolutionColor = 'text-primary' + let resolutionString = 'Yes' + switch (resolution) { + case 'YES': + break + case 'NO': + resolutionColor = 'text-red-500' + resolutionString = 'No' + break + case 'CANCEL': + resolutionColor = 'text-yellow-500' + resolutionString = 'N/A' + break + case 'MKT': + resolutionColor = 'text-blue-500' + resolutionString = numericValue ? numericValue : probability + break + } + + const resolutionDiv = ` + <span class='text-center ${resolutionColor}'> + <div class="text-8xl"> + ${resolutionString} + </div> + <div class="text-4xl">${ + resolution === 'CANCEL' ? '' : 'resolved' + }</div> + </span>` + + const probabilityDiv = ` + <span class='text-primary text-center'> + <div class="text-8xl">${probability}</div> + <div class="text-4xl">chance</div> + </span>` + + const numericValueDiv = ` + <span class='text-blue-500 text-center'> + <div class="text-8xl ">${numericValue}</div> + <div class="text-4xl">expected</div> + </span> + ` + return `<!DOCTYPE html> <html> <head> @@ -108,7 +72,7 @@ export function getHtml(parsedReq: ParsedRequest) { <script src="https://cdn.tailwindcss.com"></script> </head> <style> - ${getCss(theme, fontSize)} + ${getTemplateCss(theme, fontSize)} </style> <body> <div class="px-24"> @@ -148,21 +112,20 @@ export function getHtml(parsedReq: ParsedRequest) { <div class="text-indigo-700 text-6xl leading-tight"> ${truncatedQuestion} </div> - <div class="flex flex-col text-primary"> - <div class="text-8xl">${probability}</div> - <div class="text-4xl">${probability !== '' ? 'chance' : ''}</div> - <span class='text-blue-500 text-center'> - <div class="text-8xl ">${ - numericValue !== '' && probability === '' ? numericValue : '' - }</div> - <div class="text-4xl">${numericValue !== '' ? 'expected' : ''}</div> - </span> + <div class="flex flex-col"> + ${ + resolution + ? resolutionDiv + : numericValue + ? numericValueDiv + : probabilityDiv + } </div> </div> <!-- Metadata --> <div class="absolute bottom-16"> - <div class="text-gray-500 text-3xl"> + <div class="text-gray-500 text-3xl max-w-[80vw]"> ${metadata} </div> </div> diff --git a/og-image/api/_lib/types.ts b/og-image/api/_lib/types.ts index ef0a8135..ac1e7699 100644 --- a/og-image/api/_lib/types.ts +++ b/og-image/api/_lib/types.ts @@ -19,6 +19,7 @@ export interface ParsedRequest { creatorName: string creatorUsername: string creatorAvatarUrl: string + resolution: string // Challenge attributes: challengerAmount: string challengerOutcome: string From 7508d86c73cb972b8c5479ef34a5d65b6eee9406 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 1 Sep 2022 14:42:50 -0700 Subject: [PATCH 15/82] Clean up contract overview code (#823) * Don't call Date.now a million times in answers graph * Refactor contract overview code so that it's easier to understand --- web/components/answers/answers-graph.tsx | 38 ++- .../contract/contract-description.tsx | 6 +- web/components/contract/contract-details.tsx | 10 +- web/components/contract/contract-overview.tsx | 269 ++++++++++-------- .../contract/extra-contract-actions-row.tsx | 10 +- web/pages/[username]/[contractSlug].tsx | 4 + web/pages/embed/[username]/[contractSlug].tsx | 2 +- 7 files changed, 178 insertions(+), 161 deletions(-) diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx index dae3a8b5..e4167d11 100644 --- a/web/components/answers/answers-graph.tsx +++ b/web/components/answers/answers-graph.tsx @@ -18,19 +18,20 @@ export const AnswersGraph = memo(function AnswersGraph(props: { }) { const { contract, bets, height } = props const { createdTime, resolutionTime, closeTime, answers } = contract + const now = Date.now() const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome( bets, contract ) - const isClosed = !!closeTime && Date.now() > closeTime + const isClosed = !!closeTime && now > closeTime const latestTime = dayjs( resolutionTime && isClosed ? Math.min(resolutionTime, closeTime) : isClosed ? closeTime - : resolutionTime ?? Date.now() + : resolutionTime ?? now ) const { width } = useWindowSize() @@ -71,14 +72,14 @@ export const AnswersGraph = memo(function AnswersGraph(props: { const yTickValues = [0, 25, 50, 75, 100] const numXTickValues = isLargeWidth ? 5 : 2 - const startDate = new Date(contract.createdTime) - const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime) - ? latestTime.add(1, 'hours').toDate() - : latestTime.toDate() - const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2 + const startDate = dayjs(contract.createdTime) + const endDate = startDate.add(1, 'hour').isAfter(latestTime) + ? latestTime.add(1, 'hours') + : latestTime + const includeMinute = endDate.diff(startDate, 'hours') < 2 - const multiYear = !dayjs(startDate).isSame(latestTime, 'year') - const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime) + const multiYear = !startDate.isSame(latestTime, 'year') + const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime) return ( <div @@ -96,16 +97,16 @@ export const AnswersGraph = memo(function AnswersGraph(props: { }} xScale={{ type: 'time', - min: startDate, - max: endDate, + min: startDate.toDate(), + max: endDate.toDate(), }} xFormat={(d) => - formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) + formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) } axisBottom={{ tickValues: numXTickValues, format: (time) => - formatTime(+time, multiYear, lessThanAWeek, includeMinute), + formatTime(now, +time, multiYear, lessThanAWeek, includeMinute), }} colors={[ '#fca5a5', // red-300 @@ -158,23 +159,20 @@ function formatPercent(y: DatumValue) { } function formatTime( + now: number, time: number, includeYear: boolean, includeHour: boolean, includeMinute: boolean ) { const d = dayjs(time) - - if ( - d.add(1, 'minute').isAfter(Date.now()) && - d.subtract(1, 'minute').isBefore(Date.now()) - ) + if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now)) return 'Now' let format: string - if (d.isSame(Date.now(), 'day')) { + if (d.isSame(now, 'day')) { format = '[Today]' - } else if (d.add(1, 'day').isSame(Date.now(), 'day')) { + } else if (d.add(1, 'day').isSame(now, 'day')) { format = '[Yesterday]' } else { format = 'MMM D' diff --git a/web/components/contract/contract-description.tsx b/web/components/contract/contract-description.tsx index 9bffed9b..53557305 100644 --- a/web/components/contract/contract-description.tsx +++ b/web/components/contract/contract-description.tsx @@ -6,6 +6,7 @@ import Textarea from 'react-expanding-textarea' import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract' import { exhibitExts, parseTags } from 'common/util/parse' import { useAdmin } from 'web/hooks/use-admin' +import { useUser } from 'web/hooks/use-user' import { updateContract } from 'web/lib/firebase/contracts' import { Row } from '../layout/row' import { Content } from '../editor' @@ -17,11 +18,12 @@ import { insertContent } from '../editor/utils' export function ContractDescription(props: { contract: Contract - isCreator: boolean className?: string }) { - const { contract, isCreator, className } = props + const { contract, className } = props const isAdmin = useAdmin() + const user = useUser() + const isCreator = user?.id === contract.creatorId return ( <div className={clsx('mt-2 text-gray-700', className)}> {isCreator || isAdmin ? ( diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index a2432397..8edf9299 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -30,7 +30,6 @@ import { SiteLink } from 'web/components/site-link' import { getGroupLinkToDisplay, groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' import { contractMetrics } from 'common/contract-details' -import { User } from 'common/user' import { UserLink } from 'web/components/user-link' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' import { Tooltip } from 'web/components/tooltip' @@ -138,11 +137,9 @@ export function AbbrContractDetails(props: { export function ContractDetails(props: { contract: Contract - user: User | null | undefined - isCreator?: boolean disabled?: boolean }) { - const { contract, isCreator, disabled } = props + const { contract, disabled } = props const { closeTime, creatorName, @@ -153,6 +150,7 @@ export function ContractDetails(props: { } = contract const { volumeLabel, resolvedDate } = contractMetrics(contract) const user = useUser() + const isCreator = user?.id === creatorId const [open, setOpen] = useState(false) const { width } = useWindowSize() const isMobile = (width ?? 0) < 600 @@ -279,12 +277,12 @@ export function ContractDetails(props: { export function ExtraMobileContractDetails(props: { contract: Contract - user: User | null | undefined forceShowVolume?: boolean }) { - const { contract, user, forceShowVolume } = props + const { contract, forceShowVolume } = props const { volume, resolutionTime, closeTime, creatorId, uniqueBettorCount } = contract + const user = useUser() const uniqueBettors = uniqueBettorCount ?? 0 const { resolvedDate } = contractMetrics(contract) const volumeTranslation = diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 272de6c5..1bfe84de 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -1,5 +1,4 @@ import React from 'react' -import clsx from 'clsx' import { tradingAllowed } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' @@ -16,136 +15,154 @@ import { import { Bet } from 'common/bet' import BetButton from '../bet-button' import { AnswersGraph } from '../answers/answers-graph' -import { Contract, CPMMBinaryContract } from 'common/contract' -import { ContractDescription } from './contract-description' +import { + Contract, + BinaryContract, + CPMMContract, + CPMMBinaryContract, + FreeResponseContract, + MultipleChoiceContract, + NumericContract, + PseudoNumericContract, +} from 'common/contract' import { ContractDetails, ExtraMobileContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' -import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row' + +const OverviewQuestion = (props: { text: string }) => ( + <Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} /> +) + +const BetWidget = (props: { contract: CPMMContract }) => { + const user = useUser() + return ( + <Col> + <BetButton contract={props.contract} /> + {!user && ( + <div className="mt-1 text-center text-sm text-gray-500"> + (with play money!) + </div> + )} + </Col> + ) +} + +const NumericOverview = (props: { contract: NumericContract }) => { + const { contract } = props + return ( + <Col className="gap-1 md:gap-2"> + <Col className="gap-3 px-2 sm:gap-4"> + <ContractDetails contract={contract} /> + <Row className="justify-between gap-4"> + <OverviewQuestion text={contract.question} /> + <NumericResolutionOrExpectation + contract={contract} + className="hidden items-end xl:flex" + /> + </Row> + <NumericResolutionOrExpectation + className="items-center justify-between gap-4 xl:hidden" + contract={contract} + /> + </Col> + <NumericGraph contract={contract} /> + </Col> + ) +} + +const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { + const { contract, bets } = props + return ( + <Col className="gap-1 md:gap-2"> + <Col className="gap-3 px-2 sm:gap-4"> + <ContractDetails contract={contract} /> + <Row className="justify-between gap-4"> + <OverviewQuestion text={contract.question} /> + <BinaryResolutionOrChance + className="hidden items-end xl:flex" + contract={contract} + large + /> + </Row> + <Row className="items-center justify-between gap-4 xl:hidden"> + <BinaryResolutionOrChance contract={contract} /> + <ExtraMobileContractDetails contract={contract} /> + {tradingAllowed(contract) && ( + <BetWidget contract={contract as CPMMBinaryContract} /> + )} + </Row> + </Col> + <ContractProbGraph contract={contract} bets={[...bets].reverse()} /> + </Col> + ) +} + +const ChoiceOverview = (props: { + contract: FreeResponseContract | MultipleChoiceContract + bets: Bet[] +}) => { + const { contract, bets } = props + const { question, resolution } = contract + return ( + <Col className="gap-1 md:gap-2"> + <Col className="gap-3 px-2 sm:gap-4"> + <ContractDetails contract={contract} /> + <OverviewQuestion text={question} /> + {resolution && ( + <FreeResponseResolutionOrChance contract={contract} truncate="none" /> + )} + </Col> + <Col className={'mb-1 gap-y-2'}> + <AnswersGraph contract={contract} bets={[...bets].reverse()} /> + <ExtraMobileContractDetails + contract={contract} + forceShowVolume={true} + /> + </Col> + </Col> + ) +} + +const PseudoNumericOverview = (props: { + contract: PseudoNumericContract + bets: Bet[] +}) => { + const { contract, bets } = props + return ( + <Col className="gap-1 md:gap-2"> + <Col className="gap-3 px-2 sm:gap-4"> + <ContractDetails contract={contract} /> + <Row className="justify-between gap-4"> + <OverviewQuestion text={contract.question} /> + <PseudoNumericResolutionOrExpectation + contract={contract} + className="hidden items-end xl:flex" + /> + </Row> + <Row className="items-center justify-between gap-4 xl:hidden"> + <PseudoNumericResolutionOrExpectation contract={contract} /> + <ExtraMobileContractDetails contract={contract} /> + {tradingAllowed(contract) && <BetWidget contract={contract} />} + </Row> + </Col> + <ContractProbGraph contract={contract} bets={[...bets].reverse()} /> + </Col> + ) +} export const ContractOverview = (props: { contract: Contract bets: Bet[] - className?: string }) => { - const { contract, bets, className } = props - const { question, creatorId, outcomeType, resolution } = contract - - const user = useUser() - const isCreator = user?.id === creatorId - - const isBinary = outcomeType === 'BINARY' - const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' - - return ( - <Col className={clsx('mb-6', className)}> - <Col className="gap-3 px-2 sm:gap-4"> - <ContractDetails - contract={contract} - user={user} - isCreator={isCreator} - /> - <Row className="justify-between gap-4"> - <div className="text-2xl text-indigo-700 md:text-3xl"> - <Linkify text={question} /> - </div> - <Row className={'hidden gap-3 xl:flex'}> - {isBinary && ( - <BinaryResolutionOrChance - className="items-end" - contract={contract} - large - /> - )} - - {isPseudoNumeric && ( - <PseudoNumericResolutionOrExpectation - contract={contract} - className="items-end" - /> - )} - - {outcomeType === 'NUMERIC' && ( - <NumericResolutionOrExpectation - contract={contract} - className="items-end" - /> - )} - </Row> - </Row> - - {isBinary ? ( - <Row className="items-center justify-between gap-4 xl:hidden"> - <BinaryResolutionOrChance contract={contract} /> - <ExtraMobileContractDetails contract={contract} user={user} /> - {tradingAllowed(contract) && ( - <Row> - <Col> - <BetButton contract={contract as CPMMBinaryContract} /> - {!user && ( - <div className="mt-1 text-center text-sm text-gray-500"> - (with play money!) - </div> - )} - </Col> - </Row> - )} - </Row> - ) : isPseudoNumeric ? ( - <Row className="items-center justify-between gap-4 xl:hidden"> - <PseudoNumericResolutionOrExpectation contract={contract} /> - <ExtraMobileContractDetails contract={contract} user={user} /> - {tradingAllowed(contract) && ( - <Row> - <Col> - <BetButton contract={contract} /> - {!user && ( - <div className="mt-1 text-center text-sm text-gray-500"> - (with play money!) - </div> - )} - </Col> - </Row> - )} - </Row> - ) : ( - (outcomeType === 'FREE_RESPONSE' || - outcomeType === 'MULTIPLE_CHOICE') && - resolution && ( - <FreeResponseResolutionOrChance - contract={contract} - truncate="none" - /> - ) - )} - - {outcomeType === 'NUMERIC' && ( - <Row className="items-center justify-between gap-4 xl:hidden"> - <NumericResolutionOrExpectation contract={contract} /> - </Row> - )} - </Col> - <div className={'my-1 md:my-2'}></div> - {(isBinary || isPseudoNumeric) && ( - <ContractProbGraph contract={contract} bets={[...bets].reverse()} /> - )}{' '} - {(outcomeType === 'FREE_RESPONSE' || - outcomeType === 'MULTIPLE_CHOICE') && ( - <Col className={'mb-1 gap-y-2'}> - <AnswersGraph contract={contract} bets={[...bets].reverse()} /> - <ExtraMobileContractDetails - contract={contract} - user={user} - forceShowVolume={true} - /> - </Col> - )} - {outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />} - <ExtraContractActionsRow user={user} contract={contract} /> - <ContractDescription - className="px-2" - contract={contract} - isCreator={isCreator} - /> - </Col> - ) + const { contract, bets } = props + switch (contract.outcomeType) { + case 'BINARY': + return <BinaryOverview contract={contract} bets={bets} /> + case 'NUMERIC': + return <NumericOverview contract={contract} /> + case 'PSEUDO_NUMERIC': + return <PseudoNumericOverview contract={contract} bets={bets} /> + case 'FREE_RESPONSE': + case 'MULTIPLE_CHOICE': + return <ChoiceOverview contract={contract} bets={bets} /> + } } diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index 2ae370b1..f84655ec 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -5,7 +5,7 @@ import { Row } from '../layout/row' import { Contract } from 'web/lib/firebase/contracts' import React, { useState } from 'react' import { Button } from 'web/components/button' -import { User } from 'common/user' +import { useUser } from 'web/hooks/use-user' import { ShareModal } from './share-modal' import { FollowMarketButton } from 'web/components/follow-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button' @@ -15,12 +15,10 @@ import { withTracking } from 'web/lib/service/analytics' import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' import { CHALLENGES_ENABLED } from 'common/challenge' -export function ExtraContractActionsRow(props: { - contract: Contract - user: User | undefined | null -}) { - const { user, contract } = props +export function ExtraContractActionsRow(props: { contract: Contract }) { + const { contract } = props const { outcomeType, resolution } = contract + const user = useUser() const [isShareOpen, setShareOpen] = useState(false) const [openCreateChallengeModal, setOpenCreateChallengeModal] = useState(false) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index f3c48a68..aeb50488 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -36,6 +36,8 @@ import { useSaveReferral } from 'web/hooks/use-save-referral' import { User } from 'common/user' import { ContractComment } from 'common/comment' import { getOpenGraphProps } from 'common/contract-details' +import { ContractDescription } from 'web/components/contract/contract-description' +import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row' import { ContractLeaderboard, ContractTopTrades, @@ -232,6 +234,8 @@ export function ContractPageContent( )} <ContractOverview contract={contract} bets={nonChallengeBets} /> + <ExtraContractActionsRow contract={contract} /> + <ContractDescription className="mb-6 px-2" contract={contract} /> {outcomeType === 'NUMERIC' && ( <AlertBox diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index a496bf91..4a94b1db 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -103,7 +103,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { <Spacer h={3} /> <Row className="items-center justify-between gap-4 px-2"> - <ContractDetails contract={contract} user={null} disabled /> + <ContractDetails contract={contract} disabled /> {(isBinary || isPseudoNumeric) && tradingAllowed(contract) && From 00ba3b0c4870f515a0df643c9dbb701c2c912930 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 16:23:12 -0600 Subject: [PATCH 16/82] Show avatars of tippers and unique bettors (#837) * Show avatars of tippers and unique bettors * Make transparent the avatar bg * fix import --- common/notification.ts | 1 + functions/src/create-notification.ts | 54 +++++++++----- functions/src/on-create-bet.ts | 31 ++++---- web/components/button.tsx | 4 +- .../multi-user-transaction-link.tsx | 74 +++++++++++++++++++ web/components/user-link.tsx | 70 +----------------- web/pages/notifications.tsx | 74 +++++++++---------- 7 files changed, 162 insertions(+), 146 deletions(-) create mode 100644 web/components/multi-user-transaction-link.tsx diff --git a/common/notification.ts b/common/notification.ts index 657ea2c1..9ec320fa 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -15,6 +15,7 @@ export type Notification = { sourceUserUsername?: string sourceUserAvatarUrl?: string sourceText?: string + data?: string sourceContractTitle?: string sourceContractCreatorUsername?: string diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 8ed14704..131d6e85 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -151,15 +151,6 @@ export const createNotification = async ( } } - const notifyContractCreatorOfUniqueBettorsBonus = async ( - userToReasonTexts: user_to_reason_texts, - userId: string - ) => { - userToReasonTexts[userId] = { - reason: 'unique_bettors_on_your_contract', - } - } - const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -192,16 +183,6 @@ export const createNotification = async ( sourceContract ) { await notifyContractCreator(userToReasonTexts, sourceContract) - } else if ( - sourceType === 'bonus' && - sourceUpdateType === 'created' && - sourceContract - ) { - // Note: the daily bonus won't have a contract attached to it - await notifyContractCreatorOfUniqueBettorsBonus( - userToReasonTexts, - sourceContract.creatorId - ) } await createUsersNotifications(userToReasonTexts) @@ -737,3 +718,38 @@ export async function filterUserIdsForOnlyFollowerIds( ) return userIds.filter((id) => contractFollowersIds.includes(id)) } + +export const createUniqueBettorBonusNotification = async ( + contractCreatorId: string, + bettor: User, + txnId: string, + contract: Contract, + amount: number, + idempotencyKey: string +) => { + const notificationRef = firestore + .collection(`/users/${contractCreatorId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: contractCreatorId, + reason: 'unique_bettors_on_your_contract', + createdTime: Date.now(), + isSeen: false, + sourceId: txnId, + sourceType: 'bonus', + sourceUpdateType: 'created', + sourceUserName: bettor.name, + sourceUserUsername: bettor.username, + sourceUserAvatarUrl: bettor.avatarUrl, + sourceText: amount.toString(), + sourceSlug: contract.slug, + sourceTitle: contract.question, + // Perhaps not necessary, but just in case + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index ff6cf9d9..5dbebfc3 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -7,7 +7,7 @@ import { getUser, getValues, isProd, log } from './utils' import { createBetFillNotification, createBettingStreakBonusNotification, - createNotification, + createUniqueBettorBonusNotification, } from './create-notification' import { filterDefined } from '../../common/util/array' import { Contract } from '../../common/contract' @@ -54,11 +54,11 @@ export const onCreateBet = functions.firestore log(`Could not find contract ${contractId}`) return } - await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId) const bettor = await getUser(bet.userId) if (!bettor) return + await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor) await notifyFills(bet, contract, eventId, bettor) await updateBettingStreak(bettor, bet, contract, eventId) @@ -126,7 +126,7 @@ const updateBettingStreak = async ( const updateUniqueBettorsAndGiveCreatorBonus = async ( contract: Contract, eventId: string, - bettorId: string + bettor: User ) => { let previousUniqueBettorIds = contract.uniqueBettorIds @@ -147,13 +147,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( ) } - const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId) + const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id) - const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId]) + const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id]) // Update contract unique bettors if (!contract.uniqueBettorIds || isNewUniqueBettor) { log(`Got ${previousUniqueBettorIds} unique bettors`) - isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`) + isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`) await firestore.collection(`contracts`).doc(contract.id).update({ uniqueBettorIds: newUniqueBettorIds, uniqueBettorCount: newUniqueBettorIds.length, @@ -161,7 +161,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( } // No need to give a bonus for the creator's bet - if (!isNewUniqueBettor || bettorId == contract.creatorId) return + if (!isNewUniqueBettor || bettor.id == contract.creatorId) return // Create combined txn for all new unique bettors const bonusTxnDetails = { @@ -192,18 +192,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( log(`No bonus for user: ${contract.creatorId} - reason:`, result.status) } else { log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id) - await createNotification( + await createUniqueBettorBonusNotification( + contract.creatorId, + bettor, result.txn.id, - 'bonus', - 'created', - fromUser, - eventId + '-bonus', - result.txn.amount + '', - { - contract, - slug: contract.slug, - title: contract.question, - } + contract, + result.txn.amount, + eventId + '-unique-bettor-bonus' ) } } diff --git a/web/components/button.tsx b/web/components/button.tsx index dbb28122..cb39cba8 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { MouseEventHandler, ReactNode } from 'react' import clsx from 'clsx' export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' @@ -14,7 +14,7 @@ export type ColorType = export function Button(props: { className?: string - onClick?: () => void + onClick?: MouseEventHandler<any> | undefined children?: ReactNode size?: SizeType color?: ColorType diff --git a/web/components/multi-user-transaction-link.tsx b/web/components/multi-user-transaction-link.tsx new file mode 100644 index 00000000..70d273db --- /dev/null +++ b/web/components/multi-user-transaction-link.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react' +import { Row } from 'web/components/layout/row' +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' +import { formatMoney } from 'common/util/format' +import { Avatar } from 'web/components/avatar' +import { UserLink } from 'web/components/user-link' +import { Button } from 'web/components/button' + +export type MultiUserLinkInfo = { + name: string + username: string + avatarUrl: string | undefined + amount: number +} + +export function MultiUserTransactionLink(props: { + userInfos: MultiUserLinkInfo[] + modalLabel: string +}) { + const { userInfos, modalLabel } = props + const [open, setOpen] = useState(false) + const maxShowCount = 5 + return ( + <Row> + <Button + size={'xs'} + color={'gray-white'} + className={'z-10 mr-1 gap-1 bg-transparent'} + onClick={(e) => { + e.stopPropagation() + setOpen(true) + }} + > + <Row className={'gap-1'}> + {userInfos.map((userInfo, index) => + index < maxShowCount ? ( + <Row key={userInfo.username + 'shortened'}> + <Avatar + username={userInfo.username} + size={'sm'} + avatarUrl={userInfo.avatarUrl} + noLink={userInfos.length > 1} + /> + </Row> + ) : ( + <span>& {userInfos.length - maxShowCount} more</span> + ) + )} + </Row> + </Button> + <Modal open={open} setOpen={setOpen} size={'sm'}> + <Col className="items-start gap-4 rounded-md bg-white p-6"> + <span className={'text-xl'}>{modalLabel}</span> + {userInfos.map((userInfo) => ( + <Row + key={userInfo.username + 'list'} + className="w-full items-center gap-2" + > + <span className="text-primary min-w-[3.5rem]"> + +{formatMoney(userInfo.amount)} + </span> + <Avatar + username={userInfo.username} + avatarUrl={userInfo.avatarUrl} + /> + <UserLink name={userInfo.name} username={userInfo.username} /> + </Row> + ))} + </Col> + </Modal> + </Row> + ) +} diff --git a/web/components/user-link.tsx b/web/components/user-link.tsx index cc8f1a1f..e1b675a0 100644 --- a/web/components/user-link.tsx +++ b/web/components/user-link.tsx @@ -1,13 +1,7 @@ -import { linkClass, SiteLink } from 'web/components/site-link' +import { SiteLink } from 'web/components/site-link' import clsx from 'clsx' -import { Row } from 'web/components/layout/row' -import { Modal } from 'web/components/layout/modal' -import { Col } from 'web/components/layout/col' -import { useState } from 'react' -import { Avatar } from 'web/components/avatar' -import { formatMoney } from 'common/util/format' -function shortenName(name: string) { +export function shortenName(name: string) { const firstName = name.split(' ')[0] const maxLength = 11 const shortName = @@ -38,63 +32,3 @@ export function UserLink(props: { </SiteLink> ) } - -export type MultiUserLinkInfo = { - name: string - username: string - avatarUrl: string | undefined - amountTipped: number -} - -export function MultiUserTipLink(props: { - userInfos: MultiUserLinkInfo[] - className?: string -}) { - const { userInfos, className } = props - const [open, setOpen] = useState(false) - const maxShowCount = 2 - return ( - <> - <Row - className={clsx('mr-1 inline-flex gap-1', linkClass, className)} - onClick={(e) => { - e.stopPropagation() - setOpen(true) - }} - > - {userInfos.map((userInfo, index) => - index < maxShowCount ? ( - <span key={userInfo.username + 'shortened'} className={linkClass}> - {shortenName(userInfo.name) + - (index < maxShowCount - 1 ? ', ' : '')} - </span> - ) : ( - <span className={linkClass}> - & {userInfos.length - maxShowCount} more - </span> - ) - )} - </Row> - <Modal open={open} setOpen={setOpen} size={'sm'}> - <Col className="items-start gap-4 rounded-md bg-white p-6"> - <span className={'text-xl'}>Who tipped you</span> - {userInfos.map((userInfo) => ( - <Row - key={userInfo.username + 'list'} - className="w-full items-center gap-2" - > - <span className="text-primary min-w-[3.5rem]"> - +{formatMoney(userInfo.amountTipped)} - </span> - <Avatar - username={userInfo.username} - avatarUrl={userInfo.avatarUrl} - /> - <UserLink name={userInfo.name} username={userInfo.username} /> - </Row> - ))} - </Col> - </Modal> - </> - ) -} diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 2b2e8d7a..2ec3ac6f 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -43,12 +43,13 @@ import { SiteLink } from 'web/components/site-link' import { NotificationSettings } from 'web/components/NotificationSettings' import { SEO } from 'web/components/SEO' import { usePrivateUser, useUser } from 'web/hooks/use-user' -import { - MultiUserTipLink, - MultiUserLinkInfo, - UserLink, -} from 'web/components/user-link' +import { UserLink } from 'web/components/user-link' import { LoadingIndicator } from 'web/components/loading-indicator' +import { + MultiUserLinkInfo, + MultiUserTransactionLink, +} from 'web/components/multi-user-transaction-link' +import { Col } from 'web/components/layout/col' export const NOTIFICATIONS_PER_PAGE = 30 const HIGHLIGHT_CLASS = 'bg-indigo-50' @@ -212,7 +213,7 @@ function IncomeNotificationGroupItem(props: { function combineNotificationsByAddingNumericSourceTexts( notifications: Notification[] ) { - const newNotifications = [] + const newNotifications: Notification[] = [] const groupedNotificationsBySourceType = groupBy( notifications, (n) => n.sourceType @@ -228,10 +229,7 @@ function IncomeNotificationGroupItem(props: { for (const sourceTitle in groupedNotificationsBySourceTitle) { const notificationsForSourceTitle = groupedNotificationsBySourceTitle[sourceTitle] - if (notificationsForSourceTitle.length === 1) { - newNotifications.push(notificationsForSourceTitle[0]) - continue - } + let sum = 0 notificationsForSourceTitle.forEach( (notification) => @@ -251,7 +249,7 @@ function IncomeNotificationGroupItem(props: { username: notification.sourceUserUsername, name: notification.sourceUserName, avatarUrl: notification.sourceUserAvatarUrl, - amountTipped: thisSum, + amount: thisSum, } as MultiUserLinkInfo }), (n) => n.username @@ -260,10 +258,8 @@ function IncomeNotificationGroupItem(props: { const newNotification = { ...notificationsForSourceTitle[0], sourceText: sum.toString(), - sourceUserUsername: - uniqueUsers.length > 1 - ? JSON.stringify(uniqueUsers) - : notificationsForSourceTitle[0].sourceType, + sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername, + data: JSON.stringify(uniqueUsers), } newNotifications.push(newNotification) } @@ -372,12 +368,15 @@ function IncomeNotificationItem(props: { justSummary?: boolean }) { const { notification, justSummary } = props - const { sourceType, sourceUserName, sourceUserUsername, sourceText } = - notification + const { sourceType, sourceUserUsername, sourceText, data } = notification const [highlighted] = useState(!notification.isSeen) const { width } = useWindowSize() const isMobile = (width && width < 768) || false const user = useUser() + const isTip = sourceType === 'tip' || sourceType === 'tip_and_like' + const isUniqueBettorBonus = sourceType === 'bonus' + const userLinks: MultiUserLinkInfo[] = + isTip || isUniqueBettorBonus ? JSON.parse(data ?? '{}') : [] useEffect(() => { setNotificationsAsSeen([notification]) @@ -505,29 +504,26 @@ function IncomeNotificationItem(props: { href={getIncomeSourceUrl() ?? ''} className={'absolute left-0 right-0 top-0 bottom-0 z-0'} /> - <Row className={'items-center text-gray-500 sm:justify-start'}> - <div className={'line-clamp-2 flex max-w-xl shrink '}> - <div className={'inline'}> - <span className={'mr-1'}>{incomeNotificationLabel()}</span> - </div> - <span> - {(sourceType === 'tip' || sourceType === 'tip_and_like') && - (sourceUserUsername?.includes(',') ? ( - <MultiUserTipLink - userInfos={JSON.parse(sourceUserUsername)} - /> - ) : ( - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-1 flex-shrink-0'} - short={true} - /> - ))} - {reasonAndLink(false)} + <Col className={'justify-start text-gray-500'}> + {(isTip || isUniqueBettorBonus) && ( + <MultiUserTransactionLink + userInfos={userLinks} + modalLabel={isTip ? 'Who tipped you' : 'Unique bettors'} + /> + )} + <Row className={'line-clamp-2 flex max-w-xl'}> + <span>{incomeNotificationLabel()}</span> + <span className={'mx-1'}> + {isTip && + (userLinks.length > 1 + ? 'Multiple users' + : userLinks.length > 0 + ? userLinks[0].name + : '')} </span> - </div> - </Row> + <span>{reasonAndLink(false)}</span> + </Row> + </Col> <div className={'border-b border-gray-300 pt-4'} /> </div> </div> From 51fe44f877f0ff6111069899de622b39000ee117 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 1 Sep 2022 16:10:39 -0700 Subject: [PATCH 17/82] Show the number of open markets on each groups page --- web/pages/group/[...slugs]/index.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index c9581be5..9012b585 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -84,9 +84,12 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { [] const creator = await creatorPromise + // Only count unresolved markets + const contractsCount = contracts.filter((c) => !c.isResolved).length return { props: { + contractsCount, group, members, creator, @@ -127,6 +130,7 @@ const groupSubpages = [ ] as const export default function GroupPage(props: { + contractsCount: number group: Group | null members: User[] creator: User @@ -139,6 +143,7 @@ export default function GroupPage(props: { suggestedFilter: 'open' | 'all' }) { props = usePropz(props, getStaticPropz) ?? { + contractsCount: 0, group: null, members: [], creator: null, @@ -150,6 +155,7 @@ export default function GroupPage(props: { suggestedFilter: 'open', } const { + contractsCount, creator, members, traderScores, @@ -225,6 +231,7 @@ export default function GroupPage(props: { const tabs = [ { + badge: `${contractsCount}`, title: 'Markets', content: questionsTab, href: groupPath(group.slug, 'markets'), From 04e8bb248be5720237a3d478ae6222118d45047e Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 1 Sep 2022 18:15:10 -0700 Subject: [PATCH 18/82] Fix Salem Center market url --- web/pages/tournaments/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index 4f66cc22..b1f84473 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -69,7 +69,7 @@ const Salem = { }, { marketUrl: - 'https://salemcenter.manifold.markets/SalemCenter/over-100000-monkeypox-cases-in-2022', + 'https://salemcenter.manifold.markets/SalemCenter/supreme-court-ban-race-in-college-a', image: race_pic, }, ], From dca7205a4768bbf4ea02b7b6d5688a1fe26bd575 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 1 Sep 2022 19:37:41 -0700 Subject: [PATCH 19/82] Disable group prefetching from contract links (#836) * Kill dead code * Stop prefetching groups when viewing contract * Tidy markup --- web/components/contract/contract-details.tsx | 58 +++++++------------- 1 file changed, 19 insertions(+), 39 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 8edf9299..e0eda8d6 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -8,6 +8,7 @@ import { import clsx from 'clsx' import { Editor } from '@tiptap/react' import dayjs from 'dayjs' +import Link from 'next/link' import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' @@ -26,7 +27,7 @@ import { Button } from 'web/components/button' import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' import { ContractGroupsList } from 'web/components/groups/contract-groups-list' -import { SiteLink } from 'web/components/site-link' +import { linkClass } from 'web/components/site-link' import { getGroupLinkToDisplay, groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' import { contractMetrics } from 'common/contract-details' @@ -83,12 +84,11 @@ export function MiscDetails(props: { )} {!hideGroupLink && groupToDisplay && ( - <SiteLink - href={groupPath(groupToDisplay.slug)} - className="truncate text-sm text-gray-400" - > - {groupToDisplay.name} - </SiteLink> + <Link prefetch={false} href={groupPath(groupToDisplay.slug)}> + <a className={clsx(linkClass, 'truncate text-sm text-gray-400')}> + {groupToDisplay.name} + </a> + </Link> )} </Row> ) @@ -116,25 +116,6 @@ export function AvatarDetails(props: { ) } -export function AbbrContractDetails(props: { - contract: Contract - showHotVolume?: boolean - showTime?: ShowTime -}) { - const { contract, showHotVolume, showTime } = props - return ( - <Row className="items-center justify-between"> - <AvatarDetails contract={contract} /> - - <MiscDetails - contract={contract} - showHotVolume={showHotVolume} - showTime={showTime} - /> - </Row> - ) -} - export function ContractDetails(props: { contract: Contract disabled?: boolean @@ -156,19 +137,18 @@ export function ContractDetails(props: { const isMobile = (width ?? 0) < 600 const groupToDisplay = getGroupLinkToDisplay(contract) const groupInfo = groupToDisplay ? ( - <Row - className={clsx( - 'items-center pr-0 sm:pr-2', - isMobile ? 'max-w-[140px]' : 'max-w-[250px]' - )} - > - <SiteLink href={groupPath(groupToDisplay.slug)} className={'truncate'}> - <Row> - <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> - <span className="items-center truncate">{groupToDisplay.name}</span> - </Row> - </SiteLink> - </Row> + <Link prefetch={false} href={groupPath(groupToDisplay.slug)}> + <a + className={clsx( + linkClass, + 'flex flex-row items-center truncate pr-0 sm:pr-2', + isMobile ? 'max-w-[140px]' : 'max-w-[250px]' + )} + > + <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> + <span className="items-center truncate">{groupToDisplay.name}</span> + </a> + </Link> ) : ( <Button size={'xs'} From 4406e53121efeb362e68609fe21768dad34a4dc1 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 1 Sep 2022 19:38:09 -0700 Subject: [PATCH 20/82] Make prefetching correctly use context cache (#835) --- web/components/following-button.tsx | 13 ++----- web/components/referrals-button.tsx | 6 +-- web/hooks/use-contracts.ts | 13 ++++--- web/hooks/use-portfolio-history.ts | 15 +++++--- web/hooks/use-prefetch.ts | 15 ++++---- web/hooks/use-user-bets.ts | 11 +++--- web/hooks/use-user.ts | 15 ++++---- web/lib/firebase/bets.ts | 38 +++++++------------ web/lib/firebase/contracts.ts | 4 ++ web/lib/firebase/users.ts | 4 ++ .../api/v0/user/[username]/bets/index.ts | 5 ++- 11 files changed, 68 insertions(+), 71 deletions(-) diff --git a/web/components/following-button.tsx b/web/components/following-button.tsx index c9aecbff..fdf739a1 100644 --- a/web/components/following-button.tsx +++ b/web/components/following-button.tsx @@ -2,9 +2,9 @@ import clsx from 'clsx' import { PencilIcon } from '@heroicons/react/outline' import { User } from 'common/user' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useFollowers, useFollows } from 'web/hooks/use-follows' -import { prefetchUsers, useUser } from 'web/hooks/use-user' +import { usePrefetchUsers, useUser } from 'web/hooks/use-user' import { FollowList } from './follow-list' import { Col } from './layout/col' import { Modal } from './layout/modal' @@ -105,16 +105,9 @@ function FollowsDialog(props: { const { user, followingIds, followerIds, defaultTab, isOpen, setIsOpen } = props - useEffect(() => { - prefetchUsers([...followingIds, ...followerIds]) - }, [followingIds, followerIds]) - const currentUser = useUser() - const discoverUserIds = useDiscoverUsers(user?.id) - useEffect(() => { - prefetchUsers(discoverUserIds) - }, [discoverUserIds]) + usePrefetchUsers([...followingIds, ...followerIds, ...discoverUserIds]) return ( <Modal open={isOpen} setOpen={setIsOpen}> diff --git a/web/components/referrals-button.tsx b/web/components/referrals-button.tsx index 3cf77cfd..4b4f7095 100644 --- a/web/components/referrals-button.tsx +++ b/web/components/referrals-button.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx' import { User } from 'common/user' import { useEffect, useState } from 'react' -import { prefetchUsers, useUserById } from 'web/hooks/use-user' +import { usePrefetchUsers, useUserById } from 'web/hooks/use-user' import { Col } from './layout/col' import { Modal } from './layout/modal' import { Tabs } from './layout/tabs' @@ -56,9 +56,7 @@ function ReferralsDialog(props: { } }, [isOpen, referredByUser, user.referredByUserId]) - useEffect(() => { - prefetchUsers(referralIds) - }, [referralIds]) + usePrefetchUsers(referralIds) return ( <Modal open={isOpen} setOpen={setIsOpen}> diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 83be4636..4d7d2f79 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -9,9 +9,10 @@ import { listenForHotContracts, listenForInactiveContracts, listenForNewContracts, + getUserBetContracts, getUserBetContractsQuery, } from 'web/lib/firebase/contracts' -import { QueryClient } from 'react-query' +import { useQueryClient } from 'react-query' export const useContracts = () => { const [contracts, setContracts] = useState<Contract[] | undefined>() @@ -93,12 +94,12 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => { : undefined } -const queryClient = new QueryClient() - -export const prefetchUserBetContracts = (userId: string) => - queryClient.prefetchQuery(['contracts', 'bets', userId], () => - getUserBetContractsQuery(userId) +export const usePrefetchUserBetContracts = (userId: string) => { + const queryClient = useQueryClient() + return queryClient.prefetchQuery(['contracts', 'bets', userId], () => + getUserBetContracts(userId) ) +} export const useUserBetContracts = (userId: string) => { const result = useFirestoreQueryData( diff --git a/web/hooks/use-portfolio-history.ts b/web/hooks/use-portfolio-history.ts index 5abfdf11..1945eb7a 100644 --- a/web/hooks/use-portfolio-history.ts +++ b/web/hooks/use-portfolio-history.ts @@ -1,19 +1,22 @@ -import { QueryClient } from 'react-query' +import { useQueryClient } from 'react-query' import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { DAY_MS, HOUR_MS } from 'common/util/time' -import { getPortfolioHistoryQuery, Period } from 'web/lib/firebase/users' - -const queryClient = new QueryClient() +import { + getPortfolioHistory, + getPortfolioHistoryQuery, + Period, +} from 'web/lib/firebase/users' const getCutoff = (period: Period) => { const nowRounded = Math.round(Date.now() / HOUR_MS) * HOUR_MS return periodToCutoff(nowRounded, period).valueOf() } -export const prefetchPortfolioHistory = (userId: string, period: Period) => { +export const usePrefetchPortfolioHistory = (userId: string, period: Period) => { + const queryClient = useQueryClient() const cutoff = getCutoff(period) return queryClient.prefetchQuery(['portfolio-history', userId, cutoff], () => - getPortfolioHistoryQuery(userId, cutoff) + getPortfolioHistory(userId, cutoff) ) } diff --git a/web/hooks/use-prefetch.ts b/web/hooks/use-prefetch.ts index 3724d456..46d78b3c 100644 --- a/web/hooks/use-prefetch.ts +++ b/web/hooks/use-prefetch.ts @@ -1,11 +1,12 @@ -import { prefetchUserBetContracts } from './use-contracts' -import { prefetchPortfolioHistory } from './use-portfolio-history' -import { prefetchUserBets } from './use-user-bets' +import { usePrefetchUserBetContracts } from './use-contracts' +import { usePrefetchPortfolioHistory } from './use-portfolio-history' +import { usePrefetchUserBets } from './use-user-bets' export function usePrefetch(userId: string | undefined) { const maybeUserId = userId ?? '' - - prefetchUserBets(maybeUserId) - prefetchUserBetContracts(maybeUserId) - prefetchPortfolioHistory(maybeUserId, 'weekly') + return Promise.all([ + usePrefetchUserBets(maybeUserId), + usePrefetchUserBetContracts(maybeUserId), + usePrefetchPortfolioHistory(maybeUserId, 'weekly'), + ]) } diff --git a/web/hooks/use-user-bets.ts b/web/hooks/use-user-bets.ts index a989636f..8f0bd9f7 100644 --- a/web/hooks/use-user-bets.ts +++ b/web/hooks/use-user-bets.ts @@ -1,16 +1,17 @@ -import { QueryClient } from 'react-query' +import { useQueryClient } from 'react-query' import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useEffect, useState } from 'react' import { Bet, + getUserBets, getUserBetsQuery, listenForUserContractBets, } from 'web/lib/firebase/bets' -const queryClient = new QueryClient() - -export const prefetchUserBets = (userId: string) => - queryClient.prefetchQuery(['bets', userId], () => getUserBetsQuery(userId)) +export const usePrefetchUserBets = (userId: string) => { + const queryClient = useQueryClient() + return queryClient.prefetchQuery(['bets', userId], () => getUserBets(userId)) +} export const useUserBets = (userId: string) => { const result = useFirestoreQueryData( diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index b0cb1bc3..b355d87d 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -1,6 +1,6 @@ import { useContext } from 'react' import { useFirestoreDocumentData } from '@react-query-firebase/firestore' -import { QueryClient } from 'react-query' +import { useQueryClient } from 'react-query' import { doc, DocumentData } from 'firebase/firestore' import { getUser, User, users } from 'web/lib/firebase/users' @@ -28,12 +28,13 @@ export const useUserById = (userId = '_') => { return result.isLoading ? undefined : result.data } -const queryClient = new QueryClient() - -export const prefetchUser = (userId: string) => { - queryClient.prefetchQuery(['users', userId], () => getUser(userId)) +export const usePrefetchUser = (userId: string) => { + return usePrefetchUsers([userId])[0] } -export const prefetchUsers = (userIds: string[]) => { - userIds.forEach(prefetchUser) +export const usePrefetchUsers = (userIds: string[]) => { + const queryClient = useQueryClient() + return userIds.map((userId) => + queryClient.prefetchQuery(['users', userId], () => getUser(userId)) + ) } diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index 7f44786a..2da95f9d 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -70,20 +70,16 @@ export function listenForBets( ) } -export async function getUserBets( - userId: string, - options: { includeRedemptions: boolean } -) { - const { includeRedemptions } = options - return getValues<Bet>( - query(collectionGroup(db, 'bets'), where('userId', '==', userId)) - ) - .then((bets) => - bets.filter( - (bet) => (includeRedemptions || !bet.isRedemption) && !bet.isAnte - ) - ) - .catch((reason) => reason) +export async function getUserBets(userId: string) { + return getValues<Bet>(getUserBetsQuery(userId)) +} + +export function getUserBetsQuery(userId: string) { + return query( + collectionGroup(db, 'bets'), + where('userId', '==', userId), + orderBy('createdTime', 'desc') + ) as Query<Bet> } export async function getBets(options: { @@ -124,22 +120,16 @@ export async function getBets(options: { } export async function getContractsOfUserBets(userId: string) { - const bets: Bet[] = await getUserBets(userId, { includeRedemptions: false }) - const contractIds = uniq(bets.map((bet) => bet.contractId)) + const bets = await getUserBets(userId) + const contractIds = uniq( + bets.filter((b) => !b.isAnte).map((bet) => bet.contractId) + ) const contracts = await Promise.all( contractIds.map((contractId) => getContractFromId(contractId)) ) return filterDefined(contracts) } -export function getUserBetsQuery(userId: string) { - return query( - collectionGroup(db, 'bets'), - where('userId', '==', userId), - orderBy('createdTime', 'desc') - ) as Query<Bet> -} - export function listenForUserContractBets( userId: string, contractId: string, diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 0fea53a0..c7e32f71 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -157,6 +157,10 @@ export function listenForUserContracts( return listenForValues<Contract>(q, setContracts) } +export function getUserBetContracts(userId: string) { + return getValues<Contract>(getUserBetContractsQuery(userId)) +} + export function getUserBetContractsQuery(userId: string) { return query( contracts, diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index fc024e04..4e29fb1c 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -254,6 +254,10 @@ export async function unfollow(userId: string, unfollowedUserId: string) { await deleteDoc(followDoc) } +export function getPortfolioHistory(userId: string, since: number) { + return getValues<PortfolioMetrics>(getPortfolioHistoryQuery(userId, since)) +} + export function getPortfolioHistoryQuery(userId: string, since: number) { return query( collectionGroup(db, 'portfolioHistory'), diff --git a/web/pages/api/v0/user/[username]/bets/index.ts b/web/pages/api/v0/user/[username]/bets/index.ts index 464af52c..57648f4d 100644 --- a/web/pages/api/v0/user/[username]/bets/index.ts +++ b/web/pages/api/v0/user/[username]/bets/index.ts @@ -18,8 +18,9 @@ export default async function handler( return } - const bets = await getUserBets(user.id, { includeRedemptions: false }) + const bets = await getUserBets(user.id) + const visibleBets = bets.filter((b) => !b.isRedemption && !b.isAnte) res.setHeader('Cache-Control', 'max-age=0') - return res.status(200).json(bets) + return res.status(200).json(visibleBets) } From 8029ee49a41636db2253ed127c751d2e411276d2 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 1 Sep 2022 23:06:14 -0500 Subject: [PATCH 21/82] Fix loans bug --- common/loans.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/loans.ts b/common/loans.ts index 05b64474..e05f1c2a 100644 --- a/common/loans.ts +++ b/common/loans.ts @@ -118,7 +118,7 @@ const getFreeResponseContractLoanUpdate = ( contract: FreeResponseContract | MultipleChoiceContract, bets: Bet[] ) => { - const openBets = bets.filter((bet) => bet.isSold || bet.sale) + const openBets = bets.filter((bet) => !bet.isSold && !bet.sale) return openBets.map((bet) => { const loanAmount = bet.loanAmount ?? 0 From 0cb20d89ed005ef4cd8c75c585bb9851f7e8fa24 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 2 Sep 2022 10:35:41 -0500 Subject: [PATCH 22/82] numeric market labels: LOW/HIGH instead of MIN/MAX; eliminate payout <= MIN, etc. --- web/components/bets-list.tsx | 18 ------------------ web/pages/create.tsx | 6 +++--- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 932d689c..a8bd43f9 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -8,7 +8,6 @@ import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' import { Bet } from 'web/lib/firebase/bets' import { User } from 'web/lib/firebase/users' import { - formatLargeNumber, formatMoney, formatPercent, formatWithCommas, @@ -483,23 +482,6 @@ export function BetsSummary(props: { <div className="whitespace-nowrap">{formatMoney(noWinnings)}</div> </Col> </> - ) : isPseudoNumeric ? ( - <> - <Col> - <div className="whitespace-nowrap text-sm text-gray-500"> - Payout if {'>='} {formatLargeNumber(contract.max)} - </div> - <div className="whitespace-nowrap"> - {formatMoney(yesWinnings)} - </div> - </Col> - <Col> - <div className="whitespace-nowrap text-sm text-gray-500"> - Payout if {'<='} {formatLargeNumber(contract.min)} - </div> - <div className="whitespace-nowrap">{formatMoney(noWinnings)}</div> - </Col> - </> ) : ( <Col> <div className="whitespace-nowrap text-sm text-gray-500"> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 8ea76cef..23a88ec0 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -314,14 +314,14 @@ export function NewContract(props: { <div className="form-control mb-2 items-start"> <label className="label gap-2"> <span className="mb-1">Range</span> - <InfoTooltip text="The minimum and maximum numbers across the numeric range." /> + <InfoTooltip text="The lower and higher bounds of the numeric range. Choose bounds the value could reasonably be expected to hit." /> </label> <Row className="gap-2"> <input type="number" className="input input-bordered w-32" - placeholder="MIN" + placeholder="LOW" onClick={(e) => e.stopPropagation()} onChange={(e) => setMinString(e.target.value)} min={Number.MIN_SAFE_INTEGER} @@ -332,7 +332,7 @@ export function NewContract(props: { <input type="number" className="input input-bordered w-32" - placeholder="MAX" + placeholder="HIGH" onClick={(e) => e.stopPropagation()} onChange={(e) => setMaxString(e.target.value)} min={Number.MIN_SAFE_INTEGER} From 4c429cd5191df0cafc980f5db0694057c20cc847 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 12:51:14 -0700 Subject: [PATCH 23/82] Remove some old code related to the old feed (#843) --- web/components/feed/find-active-contracts.ts | 99 -------------------- 1 file changed, 99 deletions(-) delete mode 100644 web/components/feed/find-active-contracts.ts diff --git a/web/components/feed/find-active-contracts.ts b/web/components/feed/find-active-contracts.ts deleted file mode 100644 index ad2af970..00000000 --- a/web/components/feed/find-active-contracts.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { groupBy, mapValues, maxBy, sortBy } from 'lodash' -import { Contract } from 'web/lib/firebase/contracts' -import { ContractComment } from 'common/comment' -import { Bet } from 'common/bet' - -const MAX_ACTIVE_CONTRACTS = 75 - -// This does NOT include comment times, since those aren't part of the contract atm. -// TODO: Maybe store last activity time directly in the contract? -// Pros: simplifies this code; cons: harder to tweak "activity" definition later -function lastActivityTime(contract: Contract) { - return Math.max(contract.resolutionTime || 0, contract.createdTime) -} - -// Types of activity to surface: -// - Comment on a market -// - New market created -// - Market resolved -// - Bet on market -export function findActiveContracts( - allContracts: Contract[], - recentComments: ContractComment[], - recentBets: Bet[], - seenContracts: { [contractId: string]: number } -) { - const idToActivityTime = new Map<string, number>() - function record(contractId: string, time: number) { - // Only record if the time is newer - const oldTime = idToActivityTime.get(contractId) - idToActivityTime.set(contractId, Math.max(oldTime ?? 0, time)) - } - - const contractsById = new Map(allContracts.map((c) => [c.id, c])) - - // Record contract activity. - for (const contract of allContracts) { - record(contract.id, lastActivityTime(contract)) - } - - // Add every contract that had a recent comment, too - for (const comment of recentComments) { - if (comment.contractId) { - const contract = contractsById.get(comment.contractId) - if (contract) record(contract.id, comment.createdTime) - } - } - - // Add contracts by last bet time. - const contractBets = groupBy(recentBets, (bet) => bet.contractId) - const contractMostRecentBet = mapValues( - contractBets, - (bets) => maxBy(bets, (bet) => bet.createdTime) as Bet - ) - for (const bet of Object.values(contractMostRecentBet)) { - const contract = contractsById.get(bet.contractId) - if (contract) record(contract.id, bet.createdTime) - } - - let activeContracts = allContracts.filter( - (contract) => - contract.visibility === 'public' && - !contract.isResolved && - (contract.closeTime ?? Infinity) > Date.now() - ) - activeContracts = sortBy( - activeContracts, - (c) => -(idToActivityTime.get(c.id) ?? 0) - ) - - const contractComments = groupBy( - recentComments, - (comment) => comment.contractId - ) - const contractMostRecentComment = mapValues( - contractComments, - (comments) => maxBy(comments, (c) => c.createdTime) as ContractComment - ) - - const prioritizedContracts = sortBy(activeContracts, (c) => { - const seenTime = seenContracts[c.id] - if (!seenTime) { - return 0 - } - - const lastCommentTime = contractMostRecentComment[c.id]?.createdTime - if (lastCommentTime && lastCommentTime > seenTime) { - return 1 - } - - const lastBetTime = contractMostRecentBet[c.id]?.createdTime - if (lastBetTime && lastBetTime > seenTime) { - return 2 - } - - return seenTime - }) - - return prioritizedContracts.slice(0, MAX_ACTIVE_CONTRACTS) -} From 21b9d0efab69735d74ac75eec05a1b1fcce28c0f Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 12:51:27 -0700 Subject: [PATCH 24/82] Clean up some old pre-Amplitude tracking code (#841) --- web/components/bets-list.tsx | 11 +----- web/hooks/use-time-since-first-render.ts | 13 ------- web/lib/firebase/tracking.ts | 43 ------------------------ 3 files changed, 1 insertion(+), 66 deletions(-) delete mode 100644 web/hooks/use-time-since-first-render.ts delete mode 100644 web/lib/firebase/tracking.ts diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index a8bd43f9..b4538767 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -1,7 +1,7 @@ import Link from 'next/link' import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash' import dayjs from 'dayjs' -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import clsx from 'clsx' import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' @@ -34,8 +34,6 @@ import { resolvedPayout, getContractBetNullMetrics, } from 'common/calculate' -import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render' -import { trackLatency } from 'web/lib/firebase/tracking' import { NumericContract } from 'common/contract' import { formatNumericProbability } from 'common/pseudo-numeric' import { useUser } from 'web/hooks/use-user' @@ -84,13 +82,6 @@ export function BetsList(props: { user: User }) { const start = page * CONTRACTS_PER_PAGE const end = start + CONTRACTS_PER_PAGE - const getTime = useTimeSinceFirstRender() - useEffect(() => { - if (bets && contractsById && signedInUser) { - trackLatency(signedInUser.id, 'portfolio', getTime()) - } - }, [signedInUser, bets, contractsById, getTime]) - if (!bets || !contractsById) { return <LoadingIndicator /> } diff --git a/web/hooks/use-time-since-first-render.ts b/web/hooks/use-time-since-first-render.ts deleted file mode 100644 index da132146..00000000 --- a/web/hooks/use-time-since-first-render.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useCallback, useEffect, useRef } from 'react' - -export function useTimeSinceFirstRender() { - const startTimeRef = useRef(0) - useEffect(() => { - startTimeRef.current = Date.now() - }, []) - - return useCallback(() => { - if (!startTimeRef.current) return 0 - return Date.now() - startTimeRef.current - }, []) -} diff --git a/web/lib/firebase/tracking.ts b/web/lib/firebase/tracking.ts deleted file mode 100644 index d1828e01..00000000 --- a/web/lib/firebase/tracking.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { doc, collection, setDoc } from 'firebase/firestore' - -import { db } from './init' -import { ClickEvent, LatencyEvent, View } from 'common/tracking' - -export async function trackView(userId: string, contractId: string) { - const ref = doc(collection(db, 'private-users', userId, 'views')) - - const view: View = { - contractId, - timestamp: Date.now(), - } - - return await setDoc(ref, view) -} - -export async function trackClick(userId: string, contractId: string) { - const ref = doc(collection(db, 'private-users', userId, 'events')) - - const clickEvent: ClickEvent = { - type: 'click', - contractId, - timestamp: Date.now(), - } - - return await setDoc(ref, clickEvent) -} - -export async function trackLatency( - userId: string, - type: 'feed' | 'portfolio', - latency: number -) { - const ref = doc(collection(db, 'private-users', userId, 'latency')) - - const latencyEvent: LatencyEvent = { - type, - latency, - timestamp: Date.now(), - } - - return await setDoc(ref, latencyEvent) -} From b1bb6fab5b71854bdb1b25e041588fec367a5f84 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 12:51:41 -0700 Subject: [PATCH 25/82] Disable SSR on /home (#839) --- web/pages/home.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/web/pages/home.tsx b/web/pages/home.tsx index ff4854d7..972aa639 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -4,23 +4,14 @@ import { PencilAltIcon } from '@heroicons/react/solid' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' import { ContractSearch } from 'web/components/contract-search' -import { User } from 'common/user' -import { getUserAndPrivateUser } from 'web/lib/firebase/users' import { useTracking } from 'web/hooks/use-tracking' +import { useUser } from 'web/hooks/use-user' import { track } from 'web/lib/service/analytics' -import { authenticateOnServer } from 'web/lib/firebase/server-auth' import { useSaveReferral } from 'web/hooks/use-save-referral' -import { GetServerSideProps } from 'next' import { usePrefetch } from 'web/hooks/use-prefetch' -export const getServerSideProps: GetServerSideProps = async (ctx) => { - const creds = await authenticateOnServer(ctx) - const auth = creds ? await getUserAndPrivateUser(creds.uid) : null - return { props: { auth } } -} - -const Home = (props: { auth: { user: User } | null }) => { - const user = props.auth ? props.auth.user : null +const Home = () => { + const user = useUser() const router = useRouter() useTracking('view home') From a429a98a29c5dd9e61ee578c232070055226fbad Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 12:52:27 -0700 Subject: [PATCH 26/82] Tidy up some dead code and markup in sidebar (#842) --- web/components/create-question-button.tsx | 26 ++++++----------------- web/components/nav/menu.tsx | 10 ++++----- web/components/nav/profile-menu.tsx | 2 +- web/components/nav/sidebar.tsx | 10 +++------ web/components/notifications-icon.tsx | 12 +++++------ 5 files changed, 19 insertions(+), 41 deletions(-) diff --git a/web/components/create-question-button.tsx b/web/components/create-question-button.tsx index c7299904..20225b78 100644 --- a/web/components/create-question-button.tsx +++ b/web/components/create-question-button.tsx @@ -1,27 +1,13 @@ import React from 'react' import Link from 'next/link' -import clsx from 'clsx' - -import { User } from 'web/lib/firebase/users' import { Button } from './button' -export const CreateQuestionButton = (props: { - user: User | null | undefined - overrideText?: string - className?: string - query?: string -}) => { - const { user, overrideText, className, query } = props - - if (!user || user?.isBannedFromPosting) return <></> - +export const CreateQuestionButton = () => { return ( - <div className={clsx('flex justify-center', className)}> - <Link href={`/create${query ? query : ''}`} passHref> - <Button color="gradient" size="xl" className="mt-4"> - {overrideText ?? 'Create a market'} - </Button> - </Link> - </div> + <Link href="/create" passHref> + <Button color="gradient" size="xl" className="mt-4"> + Create a market + </Button> + </Link> ) } diff --git a/web/components/nav/menu.tsx b/web/components/nav/menu.tsx index 07ee5c77..f61ebad9 100644 --- a/web/components/nav/menu.tsx +++ b/web/components/nav/menu.tsx @@ -19,12 +19,10 @@ export function MenuButton(props: { as="div" className={clsx(className ? className : 'relative z-40 flex-shrink-0')} > - <div> - <Menu.Button className="w-full rounded-full"> - <span className="sr-only">Open user menu</span> - {buttonContent} - </Menu.Button> - </div> + <Menu.Button className="w-full rounded-full"> + <span className="sr-only">Open user menu</span> + {buttonContent} + </Menu.Button> <Transition as={Fragment} enter="transition ease-out duration-100" diff --git a/web/components/nav/profile-menu.tsx b/web/components/nav/profile-menu.tsx index 9e869c40..aad17d84 100644 --- a/web/components/nav/profile-menu.tsx +++ b/web/components/nav/profile-menu.tsx @@ -11,7 +11,7 @@ export function ProfileSummary(props: { user: User }) { <Link href={`/${user.username}?tab=bets`}> <a onClick={trackCallback('sidebar: profile')} - className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700" + className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700" > <Avatar avatarUrl={user.avatarUrl} username={user.username} noLink /> diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 1b030098..d7adfa28 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -234,11 +234,7 @@ export default function Sidebar(props: { className?: string }) { {!user && <SignInButton className="mb-4" />} - {user && ( - <div className="min-h-[80px] w-full"> - <ProfileSummary user={user} /> - </div> - )} + {user && <ProfileSummary user={user} />} {/* Mobile navigation */} <div className="flex min-h-0 shrink flex-col gap-1 lg:hidden"> @@ -255,7 +251,7 @@ export default function Sidebar(props: { className?: string }) { </div> {/* Desktop navigation */} - <div className="hidden min-h-0 shrink flex-col gap-1 lg:flex"> + <div className="hidden min-h-0 shrink flex-col items-stretch gap-1 lg:flex "> {navigationOptions.map((item) => ( <SidebarItem key={item.href} item={item} currentPage={currentPage} /> ))} @@ -264,7 +260,7 @@ export default function Sidebar(props: { className?: string }) { buttonContent={<MoreButton />} /> - {user && <CreateQuestionButton user={user} />} + {user && !user.isBannedFromPosting && <CreateQuestionButton />} </div> </nav> ) diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx index 55284e96..2438fbed 100644 --- a/web/components/notifications-icon.tsx +++ b/web/components/notifications-icon.tsx @@ -12,11 +12,9 @@ export default function NotificationsIcon(props: { className?: string }) { const privateUser = usePrivateUser() return ( - <Row className={clsx('justify-center')}> - <div className={'relative'}> - {privateUser && <UnseenNotificationsBubble privateUser={privateUser} />} - <BellIcon className={clsx(props.className)} /> - </div> + <Row className="relative justify-center"> + {privateUser && <UnseenNotificationsBubble privateUser={privateUser} />} + <BellIcon className={clsx(props.className)} /> </Row> ) } @@ -32,11 +30,11 @@ function UnseenNotificationsBubble(props: { privateUser: PrivateUser }) { const notifications = useUnseenGroupedNotification(privateUser) if (!notifications || notifications.length === 0 || seen) { - return <div /> + return null } return ( - <div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2"> + <div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:left-0 lg:-mt-1 lg:ml-2"> {notifications.length > NOTIFICATIONS_PER_PAGE ? `${NOTIFICATIONS_PER_PAGE}+` : notifications.length} From 245627a3476ff86b9bf1f49bc5c059abfe54122d Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 13:00:38 -0700 Subject: [PATCH 27/82] Temporarily patch groups loading to make dev deploy work --- web/pages/groups.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index aaf1374c..9ef2d8ff 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -21,7 +21,11 @@ import { SEO } from 'web/components/SEO' import { UserLink } from 'web/components/user-link' export async function getStaticProps() { - const groups = await listAllGroups().catch((_) => []) + let groups = await listAllGroups().catch((_) => []) + + // mqp: temporary fix to make dev deploy while Ian works on migrating groups away + // from the document array member and contracts representation + groups = groups.filter((g) => g.contractIds != null && g.memberIds != null) const creators = await Promise.all( groups.map((group) => getUser(group.creatorId)) From d1e1937195970dfcf6b2dd86a7b15954c60ee93a Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 13:04:00 -0700 Subject: [PATCH 28/82] Remove custom token generation machinery (#840) --- functions/src/get-custom-token.ts | 33 ------------------------------- functions/src/index.ts | 3 --- functions/src/serve.ts | 2 -- 3 files changed, 38 deletions(-) delete mode 100644 functions/src/get-custom-token.ts diff --git a/functions/src/get-custom-token.ts b/functions/src/get-custom-token.ts deleted file mode 100644 index 4aaaac11..00000000 --- a/functions/src/get-custom-token.ts +++ /dev/null @@ -1,33 +0,0 @@ -import * as admin from 'firebase-admin' -import { - APIError, - EndpointDefinition, - lookupUser, - parseCredentials, - writeResponseError, -} from './api' - -const opts = { - method: 'GET', - minInstances: 1, - concurrency: 100, - memory: '2GiB', - cpu: 1, -} as const - -export const getcustomtoken: EndpointDefinition = { - opts, - handler: async (req, res) => { - try { - const credentials = await parseCredentials(req) - if (credentials.kind != 'jwt') { - throw new APIError(403, 'API keys cannot mint custom tokens.') - } - const user = await lookupUser(credentials) - const token = await admin.auth().createCustomToken(user.uid) - res.status(200).json({ token: token }) - } catch (e) { - writeResponseError(e, res) - } - }, -} diff --git a/functions/src/index.ts b/functions/src/index.ts index 2ec7f3ce..9a5ec872 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -72,7 +72,6 @@ import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' -import { getcustomtoken } from './get-custom-token' import { createpost } from './create-post' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { @@ -98,7 +97,6 @@ const stripeWebhookFunction = toCloudFunction(stripewebhook) const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) -const getCustomTokenFunction = toCloudFunction(getcustomtoken) const createPostFunction = toCloudFunction(createpost) export { @@ -122,6 +120,5 @@ export { createCheckoutSessionFunction as createcheckoutsession, getCurrentUserFunction as getcurrentuser, acceptChallenge as acceptchallenge, - getCustomTokenFunction as getcustomtoken, createPostFunction as createpost, } diff --git a/functions/src/serve.ts b/functions/src/serve.ts index db847a70..a5291f19 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -26,7 +26,6 @@ import { resolvemarket } from './resolve-market' import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' -import { getcustomtoken } from './get-custom-token' import { createpost } from './create-post' type Middleware = (req: Request, res: Response, next: NextFunction) => void @@ -66,7 +65,6 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/getcurrentuser', getcurrentuser) -addEndpointRoute('/getcustomtoken', getcustomtoken) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/createpost', createpost) From b6449ad296ebf12385010f5ae75746e5e7062d4a Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 2 Sep 2022 15:32:47 -0500 Subject: [PATCH 29/82] fix bet panel warnings --- web/components/bet-panel.tsx | 64 +++++++++++++++++------------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index f958ed87..311a6182 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -8,6 +8,7 @@ import { Col } from './layout/col' import { Row } from './layout/row' import { Spacer } from './layout/spacer' import { + formatLargeNumber, formatMoney, formatPercent, formatWithCommas, @@ -28,7 +29,7 @@ import { getProbability } from 'common/calculate' import { useFocus } from 'web/hooks/use-focus' import { useUserContractBets } from 'web/hooks/use-user-bets' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' -import { getFormattedMappedValue } from 'common/pseudo-numeric' +import { getFormattedMappedValue, getMappedValue } from 'common/pseudo-numeric' import { SellRow } from './sell-row' import { useSaveBinaryShares } from './use-save-binary-shares' import { BetSignUpPrompt } from './sign-up-prompt' @@ -256,17 +257,43 @@ function BuyPanel(props: { const resultProb = getCpmmProbability(newPool, newP) const probStayedSame = formatPercent(resultProb) === formatPercent(initialProb) + const probChange = Math.abs(resultProb - initialProb) - const currentPayout = newBet.shares - const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = formatPercent(currentReturn) const format = getFormattedMappedValue(contract) + const getValue = getMappedValue(contract) + const rawDifference = Math.abs(getValue(resultProb) - getValue(initialProb)) + const displayedDifference = isPseudoNumeric + ? formatLargeNumber(rawDifference) + : formatPercent(rawDifference) + const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9) + const warning = + (betAmount ?? 0) > 10 && + bankrollFraction >= 0.5 && + bankrollFraction <= 1 ? ( + <AlertBox + title="Whoa, there!" + text={`You might not want to spend ${formatPercent( + bankrollFraction + )} of your balance on a single bet. \n\nCurrent balance: ${formatMoney( + user?.balance ?? 0 + )}`} + /> + ) : (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1 ? ( + <AlertBox + title="Whoa, there!" + text={`Are you sure you want to move the market by ${displayedDifference}?`} + /> + ) : ( + <></> + ) + return ( <Col className={hidden ? 'hidden' : ''}> <div className="my-3 text-left text-sm text-gray-500"> @@ -296,33 +323,7 @@ function BuyPanel(props: { inputRef={inputRef} /> - {(betAmount ?? 0) > 10 && - bankrollFraction >= 0.5 && - bankrollFraction <= 1 ? ( - <AlertBox - title="Whoa, there!" - text={`You might not want to spend ${formatPercent( - bankrollFraction - )} of your balance on a single bet. \n\nCurrent balance: ${formatMoney( - user?.balance ?? 0 - )}`} - /> - ) : ( - '' - )} - - {(betAmount ?? 0) > 10 && probChange >= 0.3 ? ( - <AlertBox - title="Whoa, there!" - text={`Are you sure you want to move the market ${ - isPseudoNumeric && contract.isLogScale - ? 'this much' - : format(probChange) - }?`} - /> - ) : ( - '' - )} + {warning} <Col className="mt-3 w-full gap-3"> <Row className="items-center justify-between text-sm"> @@ -351,9 +352,6 @@ function BuyPanel(props: { </> )} </div> - {/* <InfoTooltip - text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`} - /> */} </Row> <div> <span className="mr-2 whitespace-nowrap"> From 00de66cd7910205651e8c7a94f7b154fd79f8683 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 2 Sep 2022 15:59:32 -0500 Subject: [PATCH 30/82] Leaderboard calc: update profit even when portfolio didn't change (#845) * Leaderboard calc: remove didProfitChange optimization that was incorrect * Put back didPortfolioChange for deciding whether to create new history doc. --- functions/src/update-metrics.ts | 60 +++++++++++++++------------------ 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 9ef3fb10..305cd80c 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,13 +1,12 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash' +import { groupBy, isEmpty, keyBy, last, sortBy, sum, sumBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract } from '../../common/contract' import { PortfolioMetrics, User } from '../../common/user' import { calculatePayout } from '../../common/calculate' import { DAY_MS } from '../../common/util/time' -import { last } from 'lodash' import { getLoanUpdates } from '../../common/loans' const firestore = admin.firestore() @@ -88,23 +87,20 @@ export const updateMetricsCore = async () => { currentBets ) const lastPortfolio = last(portfolioHistory) - const didProfitChange = + const didPortfolioChange = lastPortfolio === undefined || lastPortfolio.balance !== newPortfolio.balance || lastPortfolio.totalDeposits !== newPortfolio.totalDeposits || lastPortfolio.investmentValue !== newPortfolio.investmentValue - const newProfit = calculateNewProfit( - portfolioHistory, - newPortfolio, - didProfitChange - ) + const newProfit = calculateNewProfit(portfolioHistory, newPortfolio) + return { user, newCreatorVolume, newPortfolio, newProfit, - didProfitChange, + didPortfolioChange, } }) @@ -120,16 +116,20 @@ export const updateMetricsCore = async () => { const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id) const userUpdates = userMetrics.map( - ({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => { + ({ + user, + newCreatorVolume, + newPortfolio, + newProfit, + didPortfolioChange, + }) => { const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 return { fieldUpdates: { doc: firestore.collection('users').doc(user.id), fields: { creatorVolumeCached: newCreatorVolume, - ...(didProfitChange && { - profitCached: newProfit, - }), + profitCached: newProfit, nextLoanCached, }, }, @@ -140,11 +140,7 @@ export const updateMetricsCore = async () => { .doc(user.id) .collection('portfolioHistory') .doc(), - fields: { - ...(didProfitChange && { - ...newPortfolio, - }), - }, + fields: didPortfolioChange ? newPortfolio : {}, }, } } @@ -171,15 +167,15 @@ const computeVolume = (contractBets: Bet[], since: number) => { const calculateProfitForPeriod = ( startTime: number, - portfolioHistory: PortfolioMetrics[], + descendingPortfolio: PortfolioMetrics[], currentProfit: number ) => { - const startingPortfolio = [...portfolioHistory] - .reverse() // so we search in descending order (most recent first), for efficiency - .find((p) => p.timestamp < startTime) + const startingPortfolio = descendingPortfolio.find( + (p) => p.timestamp < startTime + ) if (startingPortfolio === undefined) { - return 0 + return currentProfit } const startingProfit = calculateTotalProfit(startingPortfolio) @@ -233,28 +229,28 @@ const calculateNewPortfolioMetrics = ( const calculateNewProfit = ( portfolioHistory: PortfolioMetrics[], - newPortfolio: PortfolioMetrics, - didProfitChange: boolean + newPortfolio: PortfolioMetrics ) => { - if (!didProfitChange) { - return {} // early return for performance - } - const allTimeProfit = calculateTotalProfit(newPortfolio) + const descendingPortfolio = sortBy( + portfolioHistory, + (p) => p.timestamp + ).reverse() + const newProfit = { daily: calculateProfitForPeriod( Date.now() - 1 * DAY_MS, - portfolioHistory, + descendingPortfolio, allTimeProfit ), weekly: calculateProfitForPeriod( Date.now() - 7 * DAY_MS, - portfolioHistory, + descendingPortfolio, allTimeProfit ), monthly: calculateProfitForPeriod( Date.now() - 30 * DAY_MS, - portfolioHistory, + descendingPortfolio, allTimeProfit ), allTime: allTimeProfit, From 231d3e65c4a86a345d856dbc521c639ef49952fb Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 2 Sep 2022 16:19:10 -0500 Subject: [PATCH 31/82] Fix incorrect error message for no bets --- web/components/bets-list.tsx | 40 ++++++++++++++++++++--------------- web/components/pagination.tsx | 2 +- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index b4538767..2a9a76a1 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -209,26 +209,27 @@ export function BetsList(props: { user: User }) { <Col className="mt-6 divide-y"> {displayedContracts.length === 0 ? ( - <NoBets user={user} /> + <NoMatchingBets /> ) : ( - displayedContracts.map((contract) => ( - <ContractBets - key={contract.id} - contract={contract} - bets={contractBets[contract.id] ?? []} - metric={sort === 'profit' ? 'profit' : 'value'} - isYourBets={isYourBets} + <> + {displayedContracts.map((contract) => ( + <ContractBets + key={contract.id} + contract={contract} + bets={contractBets[contract.id] ?? []} + metric={sort === 'profit' ? 'profit' : 'value'} + isYourBets={isYourBets} + /> + ))} + <Pagination + page={page} + itemsPerPage={CONTRACTS_PER_PAGE} + totalItems={filteredContracts.length} + setPage={setPage} /> - )) + </> )} </Col> - - <Pagination - page={page} - itemsPerPage={CONTRACTS_PER_PAGE} - totalItems={filteredContracts.length} - setPage={setPage} - /> </Col> ) } @@ -236,7 +237,7 @@ export function BetsList(props: { user: User }) { const NoBets = ({ user }: { user: User }) => { const me = useUser() return ( - <div className="mx-4 text-gray-500"> + <div className="mx-4 py-4 text-gray-500"> {user.id === me?.id ? ( <> You have not made any bets yet.{' '} @@ -250,6 +251,11 @@ const NoBets = ({ user }: { user: User }) => { </div> ) } +const NoMatchingBets = () => ( + <div className="mx-4 py-4 text-gray-500"> + No bets matching the current filter. + </div> +) function ContractBets(props: { contract: Contract diff --git a/web/components/pagination.tsx b/web/components/pagination.tsx index 8c008ab0..8dde743c 100644 --- a/web/components/pagination.tsx +++ b/web/components/pagination.tsx @@ -58,7 +58,7 @@ export function Pagination(props: { const maxPage = Math.ceil(totalItems / itemsPerPage) - 1 - if (maxPage === 0) return <Spacer h={4} /> + if (maxPage <= 0) return <Spacer h={4} /> return ( <nav From af68fa6c42d11e340f5680250106273f09be78e5 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 2 Sep 2022 16:20:04 -0500 Subject: [PATCH 32/82] Fix typo in email followup --- functions/src/emails.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index b37f8da0..2c9c6f12 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -186,7 +186,7 @@ export const sendPersonalFollowupEmail = async ( const emailBody = `Hi ${firstName}, -Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your exprience on the platform so far? +Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your experience on the platform so far? If you haven't already, I encourage you to try creating your own prediction market (https://manifold.markets/create) and joining our Discord chat (https://discord.com/invite/eHQBNBqXuh). From 2f53cef36f4b64111c48f022bca6cc23a82d9008 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 2 Sep 2022 18:45:42 -0500 Subject: [PATCH 33/82] Move metrics calculation to common --- common/calculate-metrics.ts | 131 +++++++++++++++++++++++++++++ functions/src/update-metrics.ts | 144 +++----------------------------- 2 files changed, 143 insertions(+), 132 deletions(-) create mode 100644 common/calculate-metrics.ts diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts new file mode 100644 index 00000000..e3b8ea39 --- /dev/null +++ b/common/calculate-metrics.ts @@ -0,0 +1,131 @@ +import { sortBy, sum, sumBy } from 'lodash' +import { calculatePayout } from './calculate' +import { Bet } from './bet' +import { Contract } from './contract' +import { PortfolioMetrics, User } from './user' +import { DAY_MS } from './util/time' + +const computeInvestmentValue = ( + bets: Bet[], + contractsDict: { [k: string]: Contract } +) => { + return sumBy(bets, (bet) => { + const contract = contractsDict[bet.contractId] + if (!contract || contract.isResolved) return 0 + if (bet.sale || bet.isSold) return 0 + + const payout = calculatePayout(contract, bet, 'MKT') + const value = payout - (bet.loanAmount ?? 0) + if (isNaN(value)) return 0 + return value + }) +} + +const computeTotalPool = (userContracts: Contract[], startTime = 0) => { + const periodFilteredContracts = userContracts.filter( + (contract) => contract.createdTime >= startTime + ) + return sum( + periodFilteredContracts.map((contract) => sum(Object.values(contract.pool))) + ) +} + +export const computeVolume = (contractBets: Bet[], since: number) => { + return sumBy(contractBets, (b) => + b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0 + ) +} + +export const calculateCreatorVolume = (userContracts: Contract[]) => { + const allTimeCreatorVolume = computeTotalPool(userContracts, 0) + const monthlyCreatorVolume = computeTotalPool( + userContracts, + Date.now() - 30 * DAY_MS + ) + const weeklyCreatorVolume = computeTotalPool( + userContracts, + Date.now() - 7 * DAY_MS + ) + + const dailyCreatorVolume = computeTotalPool( + userContracts, + Date.now() - 1 * DAY_MS + ) + + return { + daily: dailyCreatorVolume, + weekly: weeklyCreatorVolume, + monthly: monthlyCreatorVolume, + allTime: allTimeCreatorVolume, + } +} + +export const calculateNewPortfolioMetrics = ( + user: User, + contractsById: { [k: string]: Contract }, + currentBets: Bet[] +) => { + const investmentValue = computeInvestmentValue(currentBets, contractsById) + const newPortfolio = { + investmentValue: investmentValue, + balance: user.balance, + totalDeposits: user.totalDeposits, + timestamp: Date.now(), + userId: user.id, + } + return newPortfolio +} + +const calculateProfitForPeriod = ( + startTime: number, + descendingPortfolio: PortfolioMetrics[], + currentProfit: number +) => { + const startingPortfolio = descendingPortfolio.find( + (p) => p.timestamp < startTime + ) + + if (startingPortfolio === undefined) { + return currentProfit + } + + const startingProfit = calculateTotalProfit(startingPortfolio) + + return currentProfit - startingProfit +} + +const calculateTotalProfit = (portfolio: PortfolioMetrics) => { + return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits +} + +export const calculateNewProfit = ( + portfolioHistory: PortfolioMetrics[], + newPortfolio: PortfolioMetrics +) => { + const allTimeProfit = calculateTotalProfit(newPortfolio) + const descendingPortfolio = sortBy( + portfolioHistory, + (p) => p.timestamp + ).reverse() + + const newProfit = { + daily: calculateProfitForPeriod( + Date.now() - 1 * DAY_MS, + descendingPortfolio, + allTimeProfit + ), + weekly: calculateProfitForPeriod( + Date.now() - 7 * DAY_MS, + descendingPortfolio, + allTimeProfit + ), + monthly: calculateProfitForPeriod( + Date.now() - 30 * DAY_MS, + descendingPortfolio, + allTimeProfit + ), + allTime: allTimeProfit, + } + + return newProfit +} diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 305cd80c..c6673969 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,42 +1,27 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { groupBy, isEmpty, keyBy, last, sortBy, sum, sumBy } from 'lodash' +import { groupBy, isEmpty, keyBy, last } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract } from '../../common/contract' import { PortfolioMetrics, User } from '../../common/user' -import { calculatePayout } from '../../common/calculate' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' +import { + calculateCreatorVolume, + calculateNewPortfolioMetrics, + calculateNewProfit, + computeVolume, +} from '../../common/calculate-metrics' const firestore = admin.firestore() -const computeInvestmentValue = ( - bets: Bet[], - contractsDict: { [k: string]: Contract } -) => { - return sumBy(bets, (bet) => { - const contract = contractsDict[bet.contractId] - if (!contract || contract.isResolved) return 0 - if (bet.sale || bet.isSold) return 0 +export const updateMetrics = functions + .runWith({ memory: '2GB', timeoutSeconds: 540 }) + .pubsub.schedule('every 15 minutes') + .onRun(updateMetricsCore) - const payout = calculatePayout(contract, bet, 'MKT') - const value = payout - (bet.loanAmount ?? 0) - if (isNaN(value)) return 0 - return value - }) -} - -const computeTotalPool = (userContracts: Contract[], startTime = 0) => { - const periodFilteredContracts = userContracts.filter( - (contract) => contract.createdTime >= startTime - ) - return sum( - periodFilteredContracts.map((contract) => sum(Object.values(contract.pool))) - ) -} - -export const updateMetricsCore = async () => { +export async function updateMetricsCore() { const [users, contracts, bets, allPortfolioHistories] = await Promise.all([ getValues<User>(firestore.collection('users')), getValues<Contract>(firestore.collection('contracts')), @@ -158,108 +143,3 @@ export const updateMetricsCore = async () => { ) log(`Updated metrics for ${users.length} users.`) } - -const computeVolume = (contractBets: Bet[], since: number) => { - return sumBy(contractBets, (b) => - b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0 - ) -} - -const calculateProfitForPeriod = ( - startTime: number, - descendingPortfolio: PortfolioMetrics[], - currentProfit: number -) => { - const startingPortfolio = descendingPortfolio.find( - (p) => p.timestamp < startTime - ) - - if (startingPortfolio === undefined) { - return currentProfit - } - - const startingProfit = calculateTotalProfit(startingPortfolio) - - return currentProfit - startingProfit -} - -const calculateTotalProfit = (portfolio: PortfolioMetrics) => { - return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits -} - -const calculateCreatorVolume = (userContracts: Contract[]) => { - const allTimeCreatorVolume = computeTotalPool(userContracts, 0) - const monthlyCreatorVolume = computeTotalPool( - userContracts, - Date.now() - 30 * DAY_MS - ) - const weeklyCreatorVolume = computeTotalPool( - userContracts, - Date.now() - 7 * DAY_MS - ) - - const dailyCreatorVolume = computeTotalPool( - userContracts, - Date.now() - 1 * DAY_MS - ) - - return { - daily: dailyCreatorVolume, - weekly: weeklyCreatorVolume, - monthly: monthlyCreatorVolume, - allTime: allTimeCreatorVolume, - } -} - -const calculateNewPortfolioMetrics = ( - user: User, - contractsById: { [k: string]: Contract }, - currentBets: Bet[] -) => { - const investmentValue = computeInvestmentValue(currentBets, contractsById) - const newPortfolio = { - investmentValue: investmentValue, - balance: user.balance, - totalDeposits: user.totalDeposits, - timestamp: Date.now(), - userId: user.id, - } - return newPortfolio -} - -const calculateNewProfit = ( - portfolioHistory: PortfolioMetrics[], - newPortfolio: PortfolioMetrics -) => { - const allTimeProfit = calculateTotalProfit(newPortfolio) - const descendingPortfolio = sortBy( - portfolioHistory, - (p) => p.timestamp - ).reverse() - - const newProfit = { - daily: calculateProfitForPeriod( - Date.now() - 1 * DAY_MS, - descendingPortfolio, - allTimeProfit - ), - weekly: calculateProfitForPeriod( - Date.now() - 7 * DAY_MS, - descendingPortfolio, - allTimeProfit - ), - monthly: calculateProfitForPeriod( - Date.now() - 30 * DAY_MS, - descendingPortfolio, - allTimeProfit - ), - allTime: allTimeProfit, - } - - return newProfit -} - -export const updateMetrics = functions - .runWith({ memory: '2GB', timeoutSeconds: 540 }) - .pubsub.schedule('every 15 minutes') - .onRun(updateMetricsCore) From cf508fd8b6f682db20db17e20e83c427951c23bd Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 2 Sep 2022 18:06:48 -0600 Subject: [PATCH 34/82] Members and contracts now subcollections of groups (#847) * Members and contracts now documents * undo loans change? * Handle closed group * Slight refactoring * Don't allow modification of private groups contracts * Add back in numMembers * Update group field names * Update firestore rules * Update firestore rules * Handle updated groups * update start numbers * Lint * Lint --- common/group.ts | 10 +- firestore.rules | 33 +- functions/src/create-group.ts | 14 +- functions/src/create-market.ts | 25 +- functions/src/create-user.ts | 24 +- functions/src/index.ts | 2 - functions/src/on-create-comment-on-group.ts | 46 --- functions/src/on-create-group.ts | 28 -- functions/src/on-update-group.ts | 65 ++- functions/src/scripts/convert-categories.ts | 108 ----- functions/src/scripts/convert-tag-to-group.ts | 63 ++- functions/src/scripts/update-groups.ts | 109 +++++ web/components/contract-search.tsx | 4 +- .../groups/contract-groups-list.tsx | 15 +- web/components/groups/edit-group-button.tsx | 13 +- web/components/groups/group-chat.tsx | 391 ------------------ web/components/groups/groups-button.tsx | 46 +-- web/hooks/use-group.ts | 86 ++-- web/lib/firebase/groups.ts | 136 +++--- web/pages/create.tsx | 4 +- web/pages/group/[...slugs]/index.tsx | 20 +- web/pages/groups.tsx | 95 ++--- web/pages/tournaments/index.tsx | 2 +- 23 files changed, 481 insertions(+), 858 deletions(-) delete mode 100644 functions/src/on-create-comment-on-group.ts delete mode 100644 functions/src/on-create-group.ts delete mode 100644 functions/src/scripts/convert-categories.ts create mode 100644 functions/src/scripts/update-groups.ts delete mode 100644 web/components/groups/group-chat.tsx diff --git a/common/group.ts b/common/group.ts index 181ad153..5c716dba 100644 --- a/common/group.ts +++ b/common/group.ts @@ -6,14 +6,16 @@ export type Group = { creatorId: string // User id createdTime: number mostRecentActivityTime: number - memberIds: string[] // User ids anyoneCanJoin: boolean - contractIds: string[] - + totalContracts: number + totalMembers: number aboutPostId?: string chatDisabled?: boolean - mostRecentChatActivityTime?: number mostRecentContractAddedTime?: number + /** @deprecated - members and contracts now stored as subcollections*/ + memberIds?: string[] // Deprecated + /** @deprecated - members and contracts now stored as subcollections*/ + contractIds?: string[] // Deprecated } export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 diff --git a/firestore.rules b/firestore.rules index e42e3ed7..15b60d0f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -160,25 +160,40 @@ service cloud.firestore { .hasOnly(['isSeen', 'viewTime']); } - match /groups/{groupId} { + match /{somePath=**}/groupMembers/{memberId} { + allow read; + } + + match /{somePath=**}/groupContracts/{contractId} { + allow read; + } + + match /groups/{groupId} { allow read; allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) .affectedKeys() - .hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin', 'aboutPostId' ]); - allow update: if (request.auth.uid in resource.data.memberIds || resource.data.anyoneCanJoin) - && request.resource.data.diff(resource.data) - .affectedKeys() - .hasOnly([ 'contractIds', 'memberIds' ]); + .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]); allow delete: if request.auth.uid == resource.data.creatorId; - function isMember() { - return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.memberIds; + match /groupContracts/{contractId} { + allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId + } + + match /groupMembers/{memberId}{ + allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin); + allow delete: if request.auth.uid == resource.data.userId; + } + + function isGroupMember() { + return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid)); } + match /comments/{commentId} { allow read; - allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember(); + allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember(); } + } match /posts/{postId} { diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index 71c6bd64..fc64aeff 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -58,13 +58,23 @@ export const creategroup = newEndpoint({}, async (req, auth) => { createdTime: Date.now(), mostRecentActivityTime: Date.now(), // TODO: allow users to add contract ids on group creation - contractIds: [], anyoneCanJoin, - memberIds, + totalContracts: 0, + totalMembers: memberIds.length, } await groupRef.create(group) + // create a GroupMemberDoc for each member + await Promise.all( + memberIds.map((memberId) => + groupRef.collection('groupMembers').doc(memberId).create({ + userId: memberId, + createdTime: Date.now(), + }) + ) + ) + return { status: 'success', group: group } }) diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index e9804f90..300d91f2 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -155,8 +155,14 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } group = groupDoc.data() as Group + const groupMembersSnap = await firestore + .collection(`groups/${groupId}/groupMembers`) + .get() + const groupMemberDocs = groupMembersSnap.docs.map( + (doc) => doc.data() as { userId: string; createdTime: number } + ) if ( - !group.memberIds.includes(user.id) && + !groupMemberDocs.map((m) => m.userId).includes(user.id) && !group.anyoneCanJoin && group.creatorId !== user.id ) { @@ -227,11 +233,20 @@ export const createmarket = newEndpoint({}, async (req, auth) => { await contractRef.create(contract) if (group != null) { - if (!group.contractIds.includes(contractRef.id)) { + const groupContractsSnap = await firestore + .collection(`groups/${groupId}/groupContracts`) + .get() + const groupContracts = groupContractsSnap.docs.map( + (doc) => doc.data() as { contractId: string; createdTime: number } + ) + if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) { await createGroupLinks(group, [contractRef.id], auth.uid) - const groupDocRef = firestore.collection('groups').doc(group.id) - groupDocRef.update({ - contractIds: uniq([...group.contractIds, contractRef.id]), + const groupContractRef = firestore + .collection(`groups/${groupId}/groupContracts`) + .doc(contract.id) + await groupContractRef.set({ + contractId: contract.id, + createdTime: Date.now(), }) } } diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 35394e90..eabe0fd0 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,6 +1,5 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { uniq } from 'lodash' import { PrivateUser, User } from '../../common/user' import { getUser, getUserByUsername, getValues } from './utils' @@ -17,7 +16,7 @@ import { import { track } from './analytics' import { APIError, newEndpoint, validate } from './api' -import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' +import { Group } from '../../common/group' import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy' const bodySchema = z.object({ @@ -117,23 +116,8 @@ const addUserToDefaultGroups = async (user: User) => { firestore.collection('groups').where('slug', '==', slug) ) await firestore - .collection('groups') - .doc(groups[0].id) - .update({ - memberIds: uniq(groups[0].memberIds.concat(user.id)), - }) - } - - for (const slug of NEW_USER_GROUP_SLUGS) { - const groups = await getValues<Group>( - firestore.collection('groups').where('slug', '==', slug) - ) - const group = groups[0] - await firestore - .collection('groups') - .doc(group.id) - .update({ - memberIds: uniq(group.memberIds.concat(user.id)), - }) + .collection(`groups/${groups[0].id}/groupMembers`) + .doc(user.id) + .set({ userId: user.id, createdTime: Date.now() }) } } diff --git a/functions/src/index.ts b/functions/src/index.ts index 9a5ec872..be73b6af 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -21,9 +21,7 @@ export * from './on-follow-user' export * from './on-unfollow-user' export * from './on-create-liquidity-provision' export * from './on-update-group' -export * from './on-create-group' export * from './on-update-user' -export * from './on-create-comment-on-group' export * from './on-create-txn' export * from './on-delete-group' export * from './score-contracts' diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts deleted file mode 100644 index 15f2bbc1..00000000 --- a/functions/src/on-create-comment-on-group.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as functions from 'firebase-functions' -import { GroupComment } from '../../common/comment' -import * as admin from 'firebase-admin' -import { Group } from '../../common/group' -import { User } from '../../common/user' -import { createGroupCommentNotification } from './create-notification' -const firestore = admin.firestore() - -export const onCreateCommentOnGroup = functions.firestore - .document('groups/{groupId}/comments/{commentId}') - .onCreate(async (change, context) => { - const { eventId } = context - const { groupId } = context.params as { - groupId: string - } - - const comment = change.data() as GroupComment - const creatorSnapshot = await firestore - .collection('users') - .doc(comment.userId) - .get() - if (!creatorSnapshot.exists) throw new Error('Could not find user') - - const groupSnapshot = await firestore - .collection('groups') - .doc(groupId) - .get() - if (!groupSnapshot.exists) throw new Error('Could not find group') - - const group = groupSnapshot.data() as Group - await firestore.collection('groups').doc(groupId).update({ - mostRecentChatActivityTime: comment.createdTime, - }) - - await Promise.all( - group.memberIds.map(async (memberId) => { - return await createGroupCommentNotification( - creatorSnapshot.data() as User, - memberId, - comment, - group, - eventId - ) - }) - ) - }) diff --git a/functions/src/on-create-group.ts b/functions/src/on-create-group.ts deleted file mode 100644 index 5209788d..00000000 --- a/functions/src/on-create-group.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as functions from 'firebase-functions' -import { getUser } from './utils' -import { createNotification } from './create-notification' -import { Group } from '../../common/group' - -export const onCreateGroup = functions.firestore - .document('groups/{groupId}') - .onCreate(async (change, context) => { - const group = change.data() as Group - const { eventId } = context - - const groupCreator = await getUser(group.creatorId) - if (!groupCreator) throw new Error('Could not find group creator') - // create notifications for all members of the group - await createNotification( - group.id, - 'group', - 'created', - groupCreator, - eventId, - group.about, - { - recipients: group.memberIds, - slug: group.slug, - title: group.name, - } - ) - }) diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index 7e6a5697..93fb5550 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -15,21 +15,68 @@ export const onUpdateGroup = functions.firestore if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) return - if (prevGroup.contractIds.length < group.contractIds.length) { - await firestore - .collection('groups') - .doc(group.id) - .update({ mostRecentContractAddedTime: Date.now() }) - //TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url - // but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two - } - await firestore .collection('groups') .doc(group.id) .update({ mostRecentActivityTime: Date.now() }) }) +export const onCreateGroupContract = functions.firestore + .document('groups/{groupId}/groupContracts/{contractId}') + .onCreate(async (change) => { + const groupId = change.ref.parent.parent?.id + if (groupId) + await firestore + .collection('groups') + .doc(groupId) + .update({ + mostRecentContractAddedTime: Date.now(), + totalContracts: admin.firestore.FieldValue.increment(1), + }) + }) + +export const onDeleteGroupContract = functions.firestore + .document('groups/{groupId}/groupContracts/{contractId}') + .onDelete(async (change) => { + const groupId = change.ref.parent.parent?.id + if (groupId) + await firestore + .collection('groups') + .doc(groupId) + .update({ + mostRecentContractAddedTime: Date.now(), + totalContracts: admin.firestore.FieldValue.increment(-1), + }) + }) + +export const onCreateGroupMember = functions.firestore + .document('groups/{groupId}/groupMembers/{memberId}') + .onCreate(async (change) => { + const groupId = change.ref.parent.parent?.id + if (groupId) + await firestore + .collection('groups') + .doc(groupId) + .update({ + mostRecentActivityTime: Date.now(), + totalMembers: admin.firestore.FieldValue.increment(1), + }) + }) + +export const onDeleteGroupMember = functions.firestore + .document('groups/{groupId}/groupMembers/{memberId}') + .onDelete(async (change) => { + const groupId = change.ref.parent.parent?.id + if (groupId) + await firestore + .collection('groups') + .doc(groupId) + .update({ + mostRecentActivityTime: Date.now(), + totalMembers: admin.firestore.FieldValue.increment(-1), + }) + }) + export async function removeGroupLinks(group: Group, contractIds: string[]) { for (const contractId of contractIds) { const contract = await getContract(contractId) diff --git a/functions/src/scripts/convert-categories.ts b/functions/src/scripts/convert-categories.ts deleted file mode 100644 index 3436bcbc..00000000 --- a/functions/src/scripts/convert-categories.ts +++ /dev/null @@ -1,108 +0,0 @@ -import * as admin from 'firebase-admin' - -import { initAdmin } from './script-init' -import { getValues, isProd } from '../utils' -import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories' -import { Group, GroupLink } from 'common/group' -import { uniq } from 'lodash' -import { Contract } from 'common/contract' -import { User } from 'common/user' -import { filterDefined } from 'common/util/array' -import { - DEV_HOUSE_LIQUIDITY_PROVIDER_ID, - HOUSE_LIQUIDITY_PROVIDER_ID, -} from 'common/antes' - -initAdmin() - -const adminFirestore = admin.firestore() - -const convertCategoriesToGroupsInternal = async (categories: string[]) => { - for (const category of categories) { - const markets = await getValues<Contract>( - adminFirestore - .collection('contracts') - .where('lowercaseTags', 'array-contains', category.toLowerCase()) - ) - const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX - const oldGroup = await getValues<Group>( - adminFirestore.collection('groups').where('slug', '==', slug) - ) - if (oldGroup.length > 0) { - console.log(`Found old group for ${category}`) - await adminFirestore.collection('groups').doc(oldGroup[0].id).delete() - } - - const allUsers = await getValues<User>(adminFirestore.collection('users')) - const groupUsers = filterDefined( - allUsers.map((user: User) => { - if (!user.followedCategories || user.followedCategories.length === 0) - return user.id - if (!user.followedCategories.includes(category.toLowerCase())) - return null - return user.id - }) - ) - - const manifoldAccount = isProd() - ? HOUSE_LIQUIDITY_PROVIDER_ID - : DEV_HOUSE_LIQUIDITY_PROVIDER_ID - const newGroupRef = await adminFirestore.collection('groups').doc() - const newGroup: Group = { - id: newGroupRef.id, - name: category, - slug, - creatorId: manifoldAccount, - createdTime: Date.now(), - anyoneCanJoin: true, - memberIds: [manifoldAccount], - about: 'Default group for all things related to ' + category, - mostRecentActivityTime: Date.now(), - contractIds: markets.map((market) => market.id), - chatDisabled: true, - } - - await adminFirestore.collection('groups').doc(newGroupRef.id).set(newGroup) - // Update group with new memberIds to avoid notifying everyone - await adminFirestore - .collection('groups') - .doc(newGroupRef.id) - .update({ - memberIds: uniq(groupUsers), - }) - - for (const market of markets) { - if (market.groupLinks?.map((l) => l.groupId).includes(newGroup.id)) - continue // already in that group - - const newGroupLinks = [ - ...(market.groupLinks ?? []), - { - groupId: newGroup.id, - createdTime: Date.now(), - slug: newGroup.slug, - name: newGroup.name, - } as GroupLink, - ] - await adminFirestore - .collection('contracts') - .doc(market.id) - .update({ - groupSlugs: uniq([...(market.groupSlugs ?? []), newGroup.slug]), - groupLinks: newGroupLinks, - }) - } - } -} - -async function convertCategoriesToGroups() { - // const defaultCategories = Object.values(DEFAULT_CATEGORIES) - const moreCategories = ['world', 'culture'] - await convertCategoriesToGroupsInternal(moreCategories) -} - -if (require.main === module) { - convertCategoriesToGroups() - .then(() => process.exit()) - .catch(console.log) -} diff --git a/functions/src/scripts/convert-tag-to-group.ts b/functions/src/scripts/convert-tag-to-group.ts index 48f14e27..3240357e 100644 --- a/functions/src/scripts/convert-tag-to-group.ts +++ b/functions/src/scripts/convert-tag-to-group.ts @@ -4,21 +4,23 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' import { isProd, log } from '../utils' import { getSlug } from '../create-group' -import { Group } from '../../../common/group' +import { Group, GroupLink } from '../../../common/group' +import { uniq } from 'lodash' +import { Contract } from 'common/contract' -const getTaggedContractIds = async (tag: string) => { +const getTaggedContracts = async (tag: string) => { const firestore = admin.firestore() const results = await firestore .collection('contracts') .where('lowercaseTags', 'array-contains', tag.toLowerCase()) .get() - return results.docs.map((d) => d.id) + return results.docs.map((d) => d.data() as Contract) } const createGroup = async ( name: string, about: string, - contractIds: string[] + contracts: Contract[] ) => { const firestore = admin.firestore() const creatorId = isProd() @@ -36,21 +38,60 @@ const createGroup = async ( about, createdTime: now, mostRecentActivityTime: now, - contractIds: contractIds, anyoneCanJoin: true, - memberIds: [], + totalContracts: contracts.length, + totalMembers: 1, } - return await groupRef.create(group) + await groupRef.create(group) + // create a GroupMemberDoc for the creator + const memberDoc = groupRef.collection('groupMembers').doc(creatorId) + await memberDoc.create({ + userId: creatorId, + createdTime: now, + }) + + // create GroupContractDocs for each contractId + await Promise.all( + contracts + .map((c) => c.id) + .map((contractId) => + groupRef.collection('groupContracts').doc(contractId).create({ + contractId, + createdTime: now, + }) + ) + ) + for (const market of contracts) { + if (market.groupLinks?.map((l) => l.groupId).includes(group.id)) continue // already in that group + + const newGroupLinks = [ + ...(market.groupLinks ?? []), + { + groupId: group.id, + createdTime: Date.now(), + slug: group.slug, + name: group.name, + } as GroupLink, + ] + await firestore + .collection('contracts') + .doc(market.id) + .update({ + groupSlugs: uniq([...(market.groupSlugs ?? []), group.slug]), + groupLinks: newGroupLinks, + }) + } + return { status: 'success', group: group } } const convertTagToGroup = async (tag: string, groupName: string) => { log(`Looking up contract IDs with tag ${tag}...`) - const contractIds = await getTaggedContractIds(tag) - log(`${contractIds.length} contracts found.`) - if (contractIds.length > 0) { + const contracts = await getTaggedContracts(tag) + log(`${contracts.length} contracts found.`) + if (contracts.length > 0) { log(`Creating group ${groupName}...`) const about = `Contracts that used to be tagged ${tag}.` - const result = await createGroup(groupName, about, contractIds) + const result = await createGroup(groupName, about, contracts) log(`Done. Group: `, result) } } diff --git a/functions/src/scripts/update-groups.ts b/functions/src/scripts/update-groups.ts new file mode 100644 index 00000000..952a0d55 --- /dev/null +++ b/functions/src/scripts/update-groups.ts @@ -0,0 +1,109 @@ +import * as admin from 'firebase-admin' +import { Group } from 'common/group' +import { initAdmin } from 'functions/src/scripts/script-init' +import { log } from '../utils' + +const getGroups = async () => { + const firestore = admin.firestore() + const groups = await firestore.collection('groups').get() + return groups.docs.map((doc) => doc.data() as Group) +} + +const createContractIdForGroup = async ( + groupId: string, + contractId: string +) => { + const firestore = admin.firestore() + const now = Date.now() + const contractDoc = await firestore + .collection('groups') + .doc(groupId) + .collection('groupContracts') + .doc(contractId) + .get() + if (!contractDoc.exists) + await firestore + .collection('groups') + .doc(groupId) + .collection('groupContracts') + .doc(contractId) + .create({ + contractId, + createdTime: now, + }) +} + +const createMemberForGroup = async (groupId: string, userId: string) => { + const firestore = admin.firestore() + const now = Date.now() + const memberDoc = await firestore + .collection('groups') + .doc(groupId) + .collection('groupMembers') + .doc(userId) + .get() + if (!memberDoc.exists) + await firestore + .collection('groups') + .doc(groupId) + .collection('groupMembers') + .doc(userId) + .create({ + userId, + createdTime: now, + }) +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function convertGroupFieldsToGroupDocuments() { + const groups = await getGroups() + for (const group of groups) { + log('updating group', group.slug) + const groupRef = admin.firestore().collection('groups').doc(group.id) + const totalMembers = (await groupRef.collection('groupMembers').get()).size + const totalContracts = (await groupRef.collection('groupContracts').get()) + .size + if ( + totalMembers === group.memberIds?.length && + totalContracts === group.contractIds?.length + ) { + log('group already converted', group.slug) + continue + } + const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1 + const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1 + for (const contractId of group.contractIds?.slice( + contractStart, + group.contractIds?.length + ) ?? []) { + await createContractIdForGroup(group.id, contractId) + } + for (const userId of group.memberIds?.slice( + membersStart, + group.memberIds?.length + ) ?? []) { + await createMemberForGroup(group.id, userId) + } + } +} + +async function updateTotalContractsAndMembers() { + const groups = await getGroups() + for (const group of groups) { + log('updating group total contracts and members', group.slug) + const groupRef = admin.firestore().collection('groups').doc(group.id) + const totalMembers = (await groupRef.collection('groupMembers').get()).size + const totalContracts = (await groupRef.collection('groupContracts').get()) + .size + await groupRef.update({ + totalMembers, + totalContracts, + }) + } +} + +if (require.main === module) { + initAdmin() + // convertGroupFieldsToGroupDocuments() + updateTotalContractsAndMembers() +} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index f8b7622e..a0396d2e 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -282,8 +282,8 @@ function ContractSearchControls(props: { : DEFAULT_CATEGORY_GROUPS.map((g) => g.slug) const memberPillGroups = sortBy( - memberGroups.filter((group) => group.contractIds.length > 0), - (group) => group.contractIds.length + memberGroups.filter((group) => group.totalContracts > 0), + (group) => group.totalContracts ).reverse() const pillGroups: { name: string; slug: string }[] = diff --git a/web/components/groups/contract-groups-list.tsx b/web/components/groups/contract-groups-list.tsx index 7bbcfa7c..d39a35d3 100644 --- a/web/components/groups/contract-groups-list.tsx +++ b/web/components/groups/contract-groups-list.tsx @@ -7,13 +7,13 @@ 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' import { Contract } from 'common/contract' import { SiteLink } from 'web/components/site-link' -import { useGroupsWithContract } from 'web/hooks/use-group' +import { useGroupsWithContract, useMemberGroupIds } from 'web/hooks/use-group' +import { Group } from 'common/group' export function ContractGroupsList(props: { contract: Contract @@ -22,6 +22,15 @@ export function ContractGroupsList(props: { const { user, contract } = props const { groupLinks } = contract const groups = useGroupsWithContract(contract) + const memberGroupIds = useMemberGroupIds(user) + + const canModifyGroupContracts = (group: Group, userId: string) => { + return ( + group.creatorId === userId || + group.anyoneCanJoin || + memberGroupIds?.includes(group.id) + ) + } return ( <Col className={'gap-2'}> <span className={'text-xl text-indigo-700'}> @@ -61,7 +70,7 @@ export function ContractGroupsList(props: { <Button color={'gray-white'} size={'xs'} - onClick={() => removeContractFromGroup(group, contract, user.id)} + onClick={() => removeContractFromGroup(group, contract)} > <XIcon className="h-4 w-4 text-gray-500" /> </Button> diff --git a/web/components/groups/edit-group-button.tsx b/web/components/groups/edit-group-button.tsx index 834af5ec..6349ad3f 100644 --- a/web/components/groups/edit-group-button.tsx +++ b/web/components/groups/edit-group-button.tsx @@ -3,17 +3,16 @@ import clsx from 'clsx' import { PencilIcon } from '@heroicons/react/outline' import { Group } from 'common/group' -import { deleteGroup, updateGroup } from 'web/lib/firebase/groups' +import { deleteGroup, joinGroup } from 'web/lib/firebase/groups' import { Spacer } from '../layout/spacer' import { useRouter } from 'next/router' import { Modal } from 'web/components/layout/modal' import { FilterSelectUsers } from 'web/components/filter-select-users' import { User } from 'common/user' -import { uniq } from 'lodash' +import { useMemberIds } from 'web/hooks/use-group' export function EditGroupButton(props: { group: Group; className?: string }) { const { group, className } = props - const { memberIds } = group const router = useRouter() const [name, setName] = useState(group.name) @@ -21,7 +20,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) { const [open, setOpen] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [addMemberUsers, setAddMemberUsers] = useState<User[]>([]) - + const memberIds = useMemberIds(group.id) function updateOpen(newOpen: boolean) { setAddMemberUsers([]) setOpen(newOpen) @@ -33,11 +32,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) { const onSubmit = async () => { setIsSubmitting(true) - await updateGroup(group, { - name, - about, - memberIds: uniq([...memberIds, ...addMemberUsers.map((user) => user.id)]), - }) + await Promise.all(addMemberUsers.map((user) => joinGroup(group, user.id))) setIsSubmitting(false) updateOpen(false) diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx deleted file mode 100644 index 9a60c9c7..00000000 --- a/web/components/groups/group-chat.tsx +++ /dev/null @@ -1,391 +0,0 @@ -import { Row } from 'web/components/layout/row' -import { Col } from 'web/components/layout/col' -import { PrivateUser, User } from 'common/user' -import React, { useEffect, memo, useState, useMemo } from 'react' -import { Avatar } from 'web/components/avatar' -import { Group } from 'common/group' -import { Comment, GroupComment } from 'common/comment' -import { createCommentOnGroup } from 'web/lib/firebase/comments' -import { CommentInputTextArea } from 'web/components/feed/feed-comments' -import { track } from 'web/lib/service/analytics' -import { firebaseLogin } from 'web/lib/firebase/users' -import { useRouter } from 'next/router' -import clsx from 'clsx' -import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' -import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' -import { Tipper } from 'web/components/tipper' -import { sum } from 'lodash' -import { formatMoney } from 'common/util/format' -import { useWindowSize } from 'web/hooks/use-window-size' -import { Content, useTextEditor } from 'web/components/editor' -import { useUnseenNotifications } from 'web/hooks/use-notifications' -import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline' -import { setNotificationsAsSeen } from 'web/pages/notifications' -import { usePrivateUser } from 'web/hooks/use-user' -import { UserLink } from 'web/components/user-link' - -export function GroupChat(props: { - messages: GroupComment[] - user: User | null | undefined - group: Group - tips: CommentTipMap -}) { - const { messages, user, group, tips } = props - - const privateUser = usePrivateUser() - - const { editor, upload } = useTextEditor({ - simple: true, - placeholder: 'Send a message', - }) - const [isSubmitting, setIsSubmitting] = useState(false) - const [scrollToBottomRef, setScrollToBottomRef] = - useState<HTMLDivElement | null>(null) - const [scrollToMessageId, setScrollToMessageId] = useState('') - const [scrollToMessageRef, setScrollToMessageRef] = - useState<HTMLDivElement | null>(null) - const [replyToUser, setReplyToUser] = useState<any>() - - const router = useRouter() - const isMember = user && group.memberIds.includes(user?.id) - - const { width, height } = useWindowSize() - const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) - // Subtract bottom bar when it's showing (less than lg screen) - const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0 - const remainingHeight = - (height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight - - // array of groups, where each group is an array of messages that are displayed as one - const groupedMessages = useMemo(() => { - // Group messages with createdTime within 2 minutes of each other. - const tempGrouped: GroupComment[][] = [] - for (let i = 0; i < messages.length; i++) { - const message = messages[i] - if (i === 0) tempGrouped.push([message]) - else { - const prevMessage = messages[i - 1] - const diff = message.createdTime - prevMessage.createdTime - const creatorsMatch = message.userId === prevMessage.userId - if (diff < 2 * 60 * 1000 && creatorsMatch) { - tempGrouped.at(-1)?.push(message) - } else { - tempGrouped.push([message]) - } - } - } - - return tempGrouped - }, [messages]) - - useEffect(() => { - scrollToMessageRef?.scrollIntoView() - }, [scrollToMessageRef]) - - useEffect(() => { - 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] - if (messages.map((m) => m.id).includes(elementInUrl)) { - setScrollToMessageId(elementInUrl) - } - }, [messages, router.asPath]) - - useEffect(() => { - // is mobile? - if (width && width > 720) focusInput() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [width]) - - function onReplyClick(comment: Comment) { - setReplyToUser({ id: comment.userId, username: comment.userUsername }) - } - - async function submitMessage() { - if (!user) { - track('sign in to comment') - return await firebaseLogin() - } - if (!editor || editor.isEmpty || isSubmitting) return - setIsSubmitting(true) - await createCommentOnGroup(group.id, editor.getJSON(), user) - editor.commands.clearContent() - setIsSubmitting(false) - setReplyToUser(undefined) - focusInput() - } - function focusInput() { - editor?.commands.focus() - } - - return ( - <Col ref={setContainerRef} style={{ height: remainingHeight }}> - <Col - className={ - 'w-full flex-1 space-y-2 overflow-x-hidden overflow-y-scroll pt-2' - } - ref={setScrollToBottomRef} - > - {groupedMessages.map((messages) => ( - <GroupMessage - user={user} - key={`group ${messages[0].id}`} - comments={messages} - group={group} - onReplyClick={onReplyClick} - highlight={messages[0].id === scrollToMessageId} - setRef={ - scrollToMessageId === messages[0].id - ? setScrollToMessageRef - : undefined - } - tips={tips[messages[0].id] ?? {}} - /> - ))} - {messages.length === 0 && ( - <div className="p-2 text-gray-500"> - No messages yet. Why not{isMember ? ` ` : ' join and '} - <button - className={'cursor-pointer font-bold text-gray-700'} - onClick={focusInput} - > - add one? - </button> - </div> - )} - </Col> - {user && group.memberIds.includes(user.id) && ( - <div className="flex w-full justify-start gap-2 p-2"> - <div className="mt-1"> - <Avatar - username={user?.username} - avatarUrl={user?.avatarUrl} - size={'sm'} - /> - </div> - <div className={'flex-1'}> - <CommentInputTextArea - editor={editor} - upload={upload} - user={user} - replyToUser={replyToUser} - submitComment={submitMessage} - isSubmitting={isSubmitting} - submitOnEnter - /> - </div> - </div> - )} - - {privateUser && ( - <GroupChatNotificationsIcon - group={group} - privateUser={privateUser} - shouldSetAsSeen={true} - hidden={true} - /> - )} - </Col> - ) -} - -export function GroupChatInBubble(props: { - messages: GroupComment[] - user: User | null | undefined - privateUser: PrivateUser | null | undefined - group: Group - tips: CommentTipMap -}) { - const { messages, user, group, tips, privateUser } = props - const [shouldShowChat, setShouldShowChat] = useState(false) - const router = useRouter() - - useEffect(() => { - const groupsWithChatEmphasis = [ - 'welcome', - 'bugs', - 'manifold-features-25bad7c7792e', - 'updates', - ] - if ( - router.asPath.includes('/chat') || - groupsWithChatEmphasis.includes( - router.asPath.split('/group/')[1].split('/')[0] - ) - ) { - setShouldShowChat(true) - } - // Leave chat open between groups if user is using chat? - else { - setShouldShowChat(false) - } - }, [router.asPath]) - - return ( - <Col - className={clsx( - 'fixed right-0 bottom-[0px] h-1 w-full sm:bottom-[20px] sm:right-20 sm:w-2/3 md:w-1/2 lg:right-24 lg:w-1/3 xl:right-32 xl:w-1/4', - shouldShowChat ? 'p-2m z-10 h-screen bg-white' : '' - )} - > - {shouldShowChat && ( - <GroupChat messages={messages} user={user} group={group} tips={tips} /> - )} - <button - type="button" - className={clsx( - 'fixed right-1 inline-flex items-center rounded-full border md:right-2 lg:right-5 xl:right-10' + - ' border-transparent p-3 text-white shadow-sm lg:p-4' + - ' focus:outline-none focus:ring-2 focus:ring-offset-2 ' + - ' bottom-[70px] ', - shouldShowChat - ? 'bottom-auto top-2 bg-gray-600 hover:bg-gray-400 focus:ring-gray-500 sm:bottom-[70px] sm:top-auto ' - : ' bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500' - )} - onClick={() => { - // router.push('/chat') - setShouldShowChat(!shouldShowChat) - track('mobile group chat button') - }} - > - {!shouldShowChat ? ( - <UsersIcon className="h-10 w-10" aria-hidden="true" /> - ) : ( - <ChevronDownIcon className={'h-10 w-10'} aria-hidden={'true'} /> - )} - {privateUser && ( - <GroupChatNotificationsIcon - group={group} - privateUser={privateUser} - shouldSetAsSeen={shouldShowChat} - hidden={false} - /> - )} - </button> - </Col> - ) -} - -function GroupChatNotificationsIcon(props: { - group: Group - privateUser: PrivateUser - shouldSetAsSeen: boolean - hidden: boolean -}) { - const { privateUser, group, shouldSetAsSeen, hidden } = props - const notificationsForThisGroup = useUnseenNotifications( - privateUser - // Disabled tracking by customHref for now. - // { - // customHref: `/group/${group.slug}`, - // } - ) - - useEffect(() => { - if (!notificationsForThisGroup) return - - notificationsForThisGroup.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, notificationsForThisGroup, shouldSetAsSeen]) - - return ( - <div - className={ - !hidden && - notificationsForThisGroup && - notificationsForThisGroup.length > 0 && - !shouldSetAsSeen - ? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500' - : 'hidden' - } - ></div> - ) -} - -const GroupMessage = memo(function GroupMessage_(props: { - user: User | null | undefined - comments: GroupComment[] - group: Group - onReplyClick?: (comment: Comment) => void - setRef?: (ref: HTMLDivElement) => void - highlight?: boolean - tips: CommentTips -}) { - const { comments, onReplyClick, group, setRef, highlight, user, tips } = props - const first = comments[0] - const { id, userUsername, userName, userAvatarUrl, createdTime } = first - - const isCreatorsComment = user && first.userId === user.id - return ( - <Col - ref={setRef} - className={clsx( - isCreatorsComment ? 'mr-2 self-end' : '', - 'w-fit max-w-sm gap-1 space-x-3 rounded-md bg-white p-1 text-sm text-gray-500 transition-colors duration-1000 sm:max-w-md sm:p-3 sm:leading-[1.3rem]', - highlight ? `-m-1 bg-indigo-500/[0.2] p-2` : '' - )} - > - <Row className={'items-center'}> - {!isCreatorsComment && ( - <Col> - <Avatar - className={'mx-2 ml-2.5'} - size={'xs'} - username={userUsername} - avatarUrl={userAvatarUrl} - /> - </Col> - )} - {!isCreatorsComment ? ( - <UserLink username={userUsername} name={userName} /> - ) : ( - <span className={'ml-2.5'}>{'You'}</span> - )} - <CopyLinkDateTimeComponent - prefix={'group'} - slug={group.slug} - createdTime={createdTime} - elementId={id} - /> - </Row> - <div className="mt-2 text-base text-black"> - {comments.map((comment) => ( - <Content - key={comment.id} - content={comment.content || comment.text} - smallImage - /> - ))} - </div> - <Row> - {!isCreatorsComment && onReplyClick && ( - <button - className={ - 'self-start py-1 text-xs font-bold text-gray-500 hover:underline' - } - onClick={() => onReplyClick(first)} - > - Reply - </button> - )} - {isCreatorsComment && sum(Object.values(tips)) > 0 && ( - <span className={'text-primary'}> - {formatMoney(sum(Object.values(tips)))} - </span> - )} - {!isCreatorsComment && <Tipper comment={first} tips={tips} />} - </Row> - </Col> - ) -}) diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index bb94c9ed..810a70bc 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -1,10 +1,10 @@ import clsx from 'clsx' import { User } from 'common/user' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useUser } from 'web/hooks/use-user' import { withTracking } from 'web/lib/service/analytics' import { Row } from 'web/components/layout/row' -import { useMemberGroups } from 'web/hooks/use-group' +import { useMemberGroups, useMemberIds } from 'web/hooks/use-group' import { TextButton } from 'web/components/text-button' import { Group } from 'common/group' import { Modal } from 'web/components/layout/modal' @@ -17,9 +17,7 @@ import toast from 'react-hot-toast' export function GroupsButton(props: { user: User }) { const { user } = props const [isOpen, setIsOpen] = useState(false) - const groups = useMemberGroups(user.id, undefined, { - by: 'mostRecentChatActivityTime', - }) + const groups = useMemberGroups(user.id) return ( <> @@ -91,34 +89,12 @@ export function JoinOrLeaveGroupButton(props: { }) { const { group, small, className } = props const currentUser = useUser() - const [isMember, setIsMember] = useState<boolean>(false) - useEffect(() => { - if (currentUser && group.memberIds.includes(currentUser.id)) { - setIsMember(group.memberIds.includes(currentUser.id)) - } - }, [currentUser, group]) - - const onJoinGroup = () => { - if (!currentUser) return - setIsMember(true) - joinGroup(group, currentUser.id).catch(() => { - setIsMember(false) - toast.error('Failed to join group') - }) - } - const onLeaveGroup = () => { - if (!currentUser) return - setIsMember(false) - leaveGroup(group, currentUser.id).catch(() => { - setIsMember(true) - toast.error('Failed to leave group') - }) - } - + const memberIds = useMemberIds(group.id) + const isMember = memberIds?.includes(currentUser?.id ?? '') ?? false const smallStyle = 'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500' - if (!currentUser || isMember === undefined) { + if (!currentUser) { if (!group.anyoneCanJoin) return <div className={clsx(className, 'text-gray-500')}>Closed</div> return ( @@ -130,6 +106,16 @@ export function JoinOrLeaveGroupButton(props: { </button> ) } + const onJoinGroup = () => { + joinGroup(group, currentUser.id).catch(() => { + toast.error('Failed to join group') + }) + } + const onLeaveGroup = () => { + leaveGroup(group, currentUser.id).catch(() => { + toast.error('Failed to leave group') + }) + } if (isMember) { return ( diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index aeeaf2ab..001c29c3 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -2,16 +2,21 @@ import { useEffect, useState } from 'react' import { Group } from 'common/group' import { User } from 'common/user' import { + GroupMemberDoc, + groupMembers, listenForGroup, + listenForGroupContractDocs, listenForGroups, + listenForMemberGroupIds, listenForMemberGroups, listenForOpenGroups, listGroups, } from 'web/lib/firebase/groups' -import { getUser, getUsers } from 'web/lib/firebase/users' +import { getUser } from 'web/lib/firebase/users' import { filterDefined } from 'common/util/array' import { Contract } from 'common/contract' import { uniq } from 'lodash' +import { listenForValues } from 'web/lib/firebase/utils' export const useGroup = (groupId: string | undefined) => { const [group, setGroup] = useState<Group | null | undefined>() @@ -43,29 +48,12 @@ export const useOpenGroups = () => { return groups } -export const useMemberGroups = ( - userId: string | null | undefined, - options?: { withChatEnabled: boolean }, - sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' } -) => { +export const useMemberGroups = (userId: string | null | undefined) => { const [memberGroups, setMemberGroups] = useState<Group[] | undefined>() useEffect(() => { if (userId) - return listenForMemberGroups( - userId, - (groups) => { - if (options?.withChatEnabled) - return setMemberGroups( - filterDefined( - groups.filter((group) => group.chatDisabled !== true) - ) - ) - return setMemberGroups(groups) - }, - sort - ) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [options?.withChatEnabled, sort?.by, userId]) + return listenForMemberGroups(userId, (groups) => setMemberGroups(groups)) + }, [userId]) return memberGroups } @@ -77,16 +65,8 @@ export const useMemberGroupIds = (user: User | null | undefined) => { useEffect(() => { if (user) { - const key = `member-groups-${user.id}` - const memberGroupJson = localStorage.getItem(key) - if (memberGroupJson) { - setMemberGroupIds(JSON.parse(memberGroupJson)) - } - - return listenForMemberGroups(user.id, (Groups) => { - const groupIds = Groups.map((group) => group.id) + return listenForMemberGroupIds(user.id, (groupIds) => { setMemberGroupIds(groupIds) - localStorage.setItem(key, JSON.stringify(groupIds)) }) } }, [user]) @@ -94,26 +74,29 @@ export const useMemberGroupIds = (user: User | null | undefined) => { return memberGroupIds } -export function useMembers(group: Group, max?: number) { +export function useMembers(groupId: string | undefined) { const [members, setMembers] = useState<User[]>([]) useEffect(() => { - const { memberIds } = group - if (memberIds.length > 0) { - listMembers(group, max).then((members) => setMembers(members)) - } - }, [group, max]) + if (groupId) + listenForValues<GroupMemberDoc>(groupMembers(groupId), (memDocs) => { + const memberIds = memDocs.map((memDoc) => memDoc.userId) + Promise.all(memberIds.map((id) => getUser(id))).then((users) => { + setMembers(users) + }) + }) + }, [groupId]) return members } -export async function listMembers(group: Group, max?: number) { - const { memberIds } = group - const numToRetrieve = max ?? memberIds.length - if (memberIds.length === 0) return [] - if (numToRetrieve > 100) - return (await getUsers()).filter((user) => - group.memberIds.includes(user.id) - ) - return await Promise.all(group.memberIds.slice(0, numToRetrieve).map(getUser)) +export function useMemberIds(groupId: string | null) { + const [memberIds, setMemberIds] = useState<string[]>([]) + useEffect(() => { + if (groupId) + return listenForValues<GroupMemberDoc>(groupMembers(groupId), (docs) => { + setMemberIds(docs.map((doc) => doc.userId)) + }) + }, [groupId]) + return memberIds } export const useGroupsWithContract = (contract: Contract) => { @@ -128,3 +111,16 @@ export const useGroupsWithContract = (contract: Contract) => { return groups } + +export function useGroupContractIds(groupId: string) { + const [contractIds, setContractIds] = useState<string[]>([]) + + useEffect(() => { + if (groupId) + return listenForGroupContractDocs(groupId, (docs) => + setContractIds(docs.map((doc) => doc.contractId)) + ) + }, [groupId]) + + return contractIds +} diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 4d22e0ee..ef67ff14 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -1,13 +1,17 @@ import { + collection, + collectionGroup, deleteDoc, deleteField, doc, getDocs, + onSnapshot, query, + setDoc, updateDoc, where, } from 'firebase/firestore' -import { sortBy, uniq } from 'lodash' +import { uniq } from 'lodash' import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group' import { coll, @@ -18,8 +22,15 @@ import { } from './utils' import { Contract } from 'common/contract' import { updateContract } from 'web/lib/firebase/contracts' +import { db } from 'web/lib/firebase/init' +import { filterDefined } from 'common/util/array' +import { getUser } from 'web/lib/firebase/users' export const groups = coll<Group>('groups') +export const groupMembers = (groupId: string) => + collection(groups, groupId, 'groupMembers') +export const groupContracts = (groupId: string) => + collection(groups, groupId, 'groupContracts') export function groupPath( groupSlug: string, @@ -33,6 +44,9 @@ export function groupPath( return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` } +export type GroupContractDoc = { contractId: string; createdTime: number } +export type GroupMemberDoc = { userId: string; createdTime: number } + export function updateGroup(group: Group, updates: Partial<Group>) { return updateDoc(doc(groups, group.id), updates) } @@ -57,6 +71,13 @@ export function listenForGroups(setGroups: (groups: Group[]) => void) { return listenForValues(groups, setGroups) } +export function listenForGroupContractDocs( + groupId: string, + setContractDocs: (docs: GroupContractDoc[]) => void +) { + return listenForValues(groupContracts(groupId), setContractDocs) +} + export function listenForOpenGroups(setGroups: (groups: Group[]) => void) { return listenForValues( query(groups, where('anyoneCanJoin', '==', true)), @@ -68,6 +89,12 @@ export function getGroup(groupId: string) { return getValue<Group>(doc(groups, groupId)) } +export function getGroupContracts(groupId: string) { + return getValues<{ contractId: string; createdTime: number }>( + groupContracts(groupId) + ) +} + export async function getGroupBySlug(slug: string) { const q = query(groups, where('slug', '==', slug)) const docs = (await getDocs(q)).docs @@ -81,33 +108,32 @@ export function listenForGroup( return listenForValue(doc(groups, groupId), setGroup) } -export function listenForMemberGroups( +export function listenForMemberGroupIds( userId: string, - setGroups: (groups: Group[]) => void, - sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' } + setGroupIds: (groupIds: string[]) => void ) { - const q = query(groups, where('memberIds', 'array-contains', userId)) - const sorter = (group: Group) => { - if (sort?.by === 'mostRecentChatActivityTime') { - return group.mostRecentChatActivityTime ?? group.createdTime - } - if (sort?.by === 'mostRecentContractAddedTime') { - return group.mostRecentContractAddedTime ?? group.createdTime - } - return group.mostRecentActivityTime - } - return listenForValues<Group>(q, (groups) => { - const sorted = sortBy(groups, [(group) => -sorter(group)]) - setGroups(sorted) + const q = query( + collectionGroup(db, 'groupMembers'), + where('userId', '==', userId) + ) + return onSnapshot(q, { includeMetadataChanges: true }, (snapshot) => { + if (snapshot.metadata.fromCache) return + + const values = snapshot.docs.map((doc) => doc.ref.parent.parent?.id) + + setGroupIds(filterDefined(values)) }) } -export async function listenForGroupsWithContractId( - contractId: string, +export function listenForMemberGroups( + userId: string, setGroups: (groups: Group[]) => void ) { - const q = query(groups, where('contractIds', 'array-contains', contractId)) - return listenForValues<Group>(q, setGroups) + return listenForMemberGroupIds(userId, (groupIds) => { + return Promise.all(groupIds.map(getGroup)).then((groups) => { + setGroups(filterDefined(groups)) + }) + }) } export async function addUserToGroupViaId(groupId: string, userId: string) { @@ -121,19 +147,18 @@ export async function addUserToGroupViaId(groupId: string, userId: string) { } export async function joinGroup(group: Group, userId: string): Promise<void> { - const { memberIds } = group - if (memberIds.includes(userId)) return // already a member - - const newMemberIds = [...memberIds, userId] - return await updateGroup(group, { memberIds: uniq(newMemberIds) }) + // create a new member document in grouoMembers collection + const memberDoc = doc(groupMembers(group.id), userId) + return await setDoc(memberDoc, { + userId, + createdTime: Date.now(), + }) } export async function leaveGroup(group: Group, userId: string): Promise<void> { - const { memberIds } = group - if (!memberIds.includes(userId)) return // not a member - - const newMemberIds = memberIds.filter((id) => id !== userId) - return await updateGroup(group, { memberIds: uniq(newMemberIds) }) + // delete the member document in groupMembers collection + const memberDoc = doc(groupMembers(group.id), userId) + return await deleteDoc(memberDoc) } export async function addContractToGroup( @@ -141,7 +166,6 @@ export async function addContractToGroup( contract: Contract, userId: string ) { - if (!canModifyGroupContracts(group, userId)) return const newGroupLinks = [ ...(contract.groupLinks ?? []), { @@ -158,25 +182,18 @@ export async function addContractToGroup( groupLinks: newGroupLinks, }) - if (!group.contractIds.includes(contract.id)) { - return await updateGroup(group, { - contractIds: uniq([...group.contractIds, contract.id]), - }) - .then(() => group) - .catch((err) => { - console.error('error adding contract to group', err) - return err - }) - } + // create new contract document in groupContracts collection + const contractDoc = doc(groupContracts(group.id), contract.id) + await setDoc(contractDoc, { + contractId: contract.id, + createdTime: Date.now(), + }) } export async function removeContractFromGroup( group: Group, - contract: Contract, - userId: string + contract: Contract ) { - 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 @@ -188,25 +205,9 @@ export async function removeContractFromGroup( }) } - if (group.contractIds.includes(contract.id)) { - const newContractIds = group.contractIds.filter((id) => id !== contract.id) - return await updateGroup(group, { - contractIds: uniq(newContractIds), - }) - .then(() => group) - .catch((err) => { - console.error('error removing contract from group', err) - return err - }) - } -} - -export function canModifyGroupContracts(group: Group, userId: string) { - return ( - group.creatorId === userId || - group.memberIds.includes(userId) || - group.anyoneCanJoin - ) + // delete the contract document in groupContracts collection + const contractDoc = doc(groupContracts(group.id), contract.id) + await deleteDoc(contractDoc) } export function getGroupLinkToDisplay(contract: Contract) { @@ -222,3 +223,8 @@ export function getGroupLinkToDisplay(contract: Contract) { : sortedGroupLinks?.[0] ?? null return groupToDisplay } + +export async function listMembers(group: Group) { + const members = await getValues<GroupMemberDoc>(groupMembers(group.id)) + return await Promise.all(members.map((m) => m.userId).map(getUser)) +} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 23a88ec0..b5892ccf 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -20,7 +20,7 @@ import { import { formatMoney } from 'common/util/format' import { removeUndefinedProps } from 'common/util/object' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { canModifyGroupContracts, getGroup } from 'web/lib/firebase/groups' +import { 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' @@ -139,7 +139,7 @@ export function NewContract(props: { useEffect(() => { if (groupId) getGroup(groupId).then((group) => { - if (group && canModifyGroupContracts(group, creator.id)) { + if (group) { setSelectedGroup(group) setShowGroupSelector(false) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 9012b585..4626aa77 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -14,13 +14,14 @@ import { getGroupBySlug, groupPath, joinGroup, + listMembers, updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' +import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group' import { scoreCreators, scoreTraders } from 'common/scoring' import { Leaderboard } from 'web/components/leaderboard' import { formatMoney } from 'common/util/format' @@ -157,7 +158,6 @@ export default function GroupPage(props: { const { contractsCount, creator, - members, traderScores, topTraders, creatorScores, @@ -174,6 +174,7 @@ export default function GroupPage(props: { const user = useUser() const isAdmin = useAdmin() + const members = useMembers(group?.id) ?? props.members useSaveReferral(user, { defaultReferrerUsername: creator.username, @@ -183,9 +184,8 @@ export default function GroupPage(props: { if (group === null || !groupSubpages.includes(page) || slugs[2]) { return <Custom404 /> } - const { memberIds } = group const isCreator = user && group && user.id === group.creatorId - const isMember = user && memberIds.includes(user.id) + const isMember = user && members.map((m) => m.id).includes(user.id) const leaderboard = ( <Col> @@ -347,8 +347,7 @@ function GroupOverview(props: { {isCreator ? ( <EditGroupButton className={'ml-1'} group={group} /> ) : ( - user && - group.memberIds.includes(user?.id) && ( + user && ( <Row> <JoinOrLeaveGroupButton group={group} /> </Row> @@ -425,7 +424,7 @@ function GroupMemberSearch(props: { members: User[]; group: Group }) { let { members } = props // Use static members on load, but also listen to member changes: - const listenToMembers = useMembers(group) + const listenToMembers = useMembers(group.id) if (listenToMembers) { members = listenToMembers } @@ -547,6 +546,7 @@ function AddContractButton(props: { group: Group; user: User }) { const [open, setOpen] = useState(false) const [contracts, setContracts] = useState<Contract[]>([]) const [loading, setLoading] = useState(false) + const groupContractIds = useGroupContractIds(group.id) async function addContractToCurrentGroup(contract: Contract) { if (contracts.map((c) => c.id).includes(contract.id)) { @@ -634,7 +634,9 @@ function AddContractButton(props: { group: Group; user: User }) { hideOrderSelector={true} onContractClick={addContractToCurrentGroup} cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} - additionalFilter={{ excludeContractIds: group.contractIds }} + additionalFilter={{ + excludeContractIds: groupContractIds, + }} highlightOptions={{ contractIds: contracts.map((c) => c.id), highlightClassName: '!bg-indigo-100 border-indigo-100 border-2', @@ -653,7 +655,7 @@ function JoinGroupButton(props: { }) { const { group, user } = props function addUserToGroup() { - if (user && !group.memberIds.includes(user.id)) { + if (user) { toast.promise(joinGroup(group, user.id), { loading: 'Joining group...', success: 'Joined group!', diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 9ef2d8ff..dfb19c69 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -7,7 +7,12 @@ import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { Title } from 'web/components/title' -import { useGroups, useMemberGroupIds, useMembers } from 'web/hooks/use-group' +import { + useGroupContractIds, + useGroups, + useMemberGroupIds, + useMemberIds, +} from 'web/hooks/use-group' import { useUser } from 'web/hooks/use-user' import { groupPath, listAllGroups } from 'web/lib/firebase/groups' import { getUser, User } from 'web/lib/firebase/users' @@ -18,7 +23,6 @@ import { Avatar } from 'web/components/avatar' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { searchInAny } from 'common/util/parse' import { SEO } from 'web/components/SEO' -import { UserLink } from 'web/components/user-link' export async function getStaticProps() { let groups = await listAllGroups().catch((_) => []) @@ -73,10 +77,7 @@ export default function Groups(props: { // List groups with the highest question count, then highest member count // TODO use find-active-contracts to sort by? - const matches = sortBy(groups, [ - (group) => -1 * group.contractIds.length, - (group) => -1 * group.memberIds.length, - ]).filter((g) => + const matches = sortBy(groups, []).filter((g) => searchInAny( query, g.name, @@ -87,10 +88,7 @@ export default function Groups(props: { const matchesOrderedByRecentActivity = sortBy(groups, [ (group) => - -1 * - (group.mostRecentChatActivityTime ?? - group.mostRecentContractAddedTime ?? - group.mostRecentActivityTime), + -1 * (group.mostRecentContractAddedTime ?? group.mostRecentActivityTime), ]).filter((g) => searchInAny( query, @@ -124,37 +122,6 @@ export default function Groups(props: { <Tabs currentPageForAnalytics={'groups'} tabs={[ - ...(user && memberGroupIds.length > 0 - ? [ - { - title: 'My Groups', - content: ( - <Col> - <input - type="text" - onChange={(e) => debouncedQuery(e.target.value)} - placeholder="Search your groups" - className="input input-bordered mb-4 w-full" - /> - - <div className="flex flex-wrap justify-center gap-4"> - {matchesOrderedByRecentActivity - .filter((match) => - memberGroupIds.includes(match.id) - ) - .map((group) => ( - <GroupCard - key={group.id} - group={group} - creator={creatorsDict[group.creatorId]} - /> - ))} - </div> - </Col> - ), - }, - ] - : []), { title: 'All', content: ( @@ -178,6 +145,31 @@ export default function Groups(props: { </Col> ), }, + { + title: 'My Groups', + content: ( + <Col> + <input + type="text" + onChange={(e) => debouncedQuery(e.target.value)} + placeholder="Search your groups" + className="input input-bordered mb-4 w-full" + /> + + <div className="flex flex-wrap justify-center gap-4"> + {matchesOrderedByRecentActivity + .filter((match) => memberGroupIds.includes(match.id)) + .map((group) => ( + <GroupCard + key={group.id} + group={group} + creator={creatorsDict[group.creatorId]} + /> + ))} + </div> + </Col> + ), + }, ]} /> </Col> @@ -188,6 +180,7 @@ export default function Groups(props: { export function GroupCard(props: { group: Group; creator: User | undefined }) { const { group, creator } = props + const groupContracts = useGroupContractIds(group.id) return ( <Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100"> <Link href={groupPath(group.slug)}> @@ -205,7 +198,7 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { <Row className="items-center justify-between gap-2"> <span className="text-xl">{group.name}</span> </Row> - <Row>{group.contractIds.length} questions</Row> + <Row>{groupContracts.length} questions</Row> <Row className="text-sm text-gray-500"> <GroupMembersList group={group} /> </Row> @@ -221,23 +214,11 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { function GroupMembersList(props: { group: Group }) { const { group } = props - const maxMembersToShow = 3 - const members = useMembers(group, maxMembersToShow).filter( - (m) => m.id !== group.creatorId - ) - if (group.memberIds.length === 1) return <div /> + const memberIds = useMemberIds(group.id) + if (memberIds.length === 1) return <div /> return ( <div className="text-neutral flex flex-wrap gap-1"> - <span className={'text-gray-500'}>Other members</span> - {members.slice(0, maxMembersToShow).map((member, i) => ( - <div key={member.id} className={'flex-shrink'}> - <UserLink name={member.name} username={member.username} /> - {members.length > 1 && i !== members.length - 1 && <span>,</span>} - </div> - ))} - {group.memberIds.length > maxMembersToShow && ( - <span> & {group.memberIds.length - maxMembersToShow} more</span> - )} + <span>{memberIds.length} members</span> </div> ) } diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index b1f84473..9bfdfb89 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -122,7 +122,7 @@ export async function getStaticProps() { const markets = Object.fromEntries(groups.map((g, i) => [g.id, contracts[i]])) const groupMap = keyBy(groups, 'id') - const numPeople = mapValues(groupMap, (g) => g?.memberIds.length) + const numPeople = mapValues(groupMap, (g) => g?.totalMembers) const slugs = mapValues(groupMap, 'slug') return { props: { markets, numPeople, slugs }, revalidate: 60 * 10 } From 9577955d2d13fb01b5029d443a2f354342aa8904 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 2 Sep 2022 18:08:53 -0600 Subject: [PATCH 35/82] Remove null check --- web/pages/groups.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index dfb19c69..76c859c3 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -25,11 +25,11 @@ import { searchInAny } from 'common/util/parse' import { SEO } from 'web/components/SEO' export async function getStaticProps() { - let groups = await listAllGroups().catch((_) => []) + const groups = await listAllGroups().catch((_) => []) // mqp: temporary fix to make dev deploy while Ian works on migrating groups away // from the document array member and contracts representation - groups = groups.filter((g) => g.contractIds != null && g.memberIds != null) + // groups = groups.filter((g) => g.contractIds != null && g.memberIds != null) const creators = await Promise.all( groups.map((group) => getUser(group.creatorId)) From 57b74a5d09eea66df25b78383c44feb83a80ae87 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 2 Sep 2022 18:12:55 -0600 Subject: [PATCH 36/82] Use cached values --- web/pages/groups.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 76c859c3..92a813aa 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -7,12 +7,7 @@ import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { Title } from 'web/components/title' -import { - useGroupContractIds, - useGroups, - useMemberGroupIds, - useMemberIds, -} from 'web/hooks/use-group' +import { useGroups, useMemberGroupIds } from 'web/hooks/use-group' import { useUser } from 'web/hooks/use-user' import { groupPath, listAllGroups } from 'web/lib/firebase/groups' import { getUser, User } from 'web/lib/firebase/users' @@ -180,7 +175,7 @@ export default function Groups(props: { export function GroupCard(props: { group: Group; creator: User | undefined }) { const { group, creator } = props - const groupContracts = useGroupContractIds(group.id) + const { totalContracts } = group return ( <Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100"> <Link href={groupPath(group.slug)}> @@ -198,7 +193,7 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { <Row className="items-center justify-between gap-2"> <span className="text-xl">{group.name}</span> </Row> - <Row>{groupContracts.length} questions</Row> + <Row>{totalContracts} questions</Row> <Row className="text-sm text-gray-500"> <GroupMembersList group={group} /> </Row> @@ -214,11 +209,11 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { function GroupMembersList(props: { group: Group }) { const { group } = props - const memberIds = useMemberIds(group.id) - if (memberIds.length === 1) return <div /> + const { totalMembers } = group + if (totalMembers === 1) return <div /> return ( <div className="text-neutral flex flex-wrap gap-1"> - <span>{memberIds.length} members</span> + <span>{totalMembers} members</span> </div> ) } From c74d972caf305da97c0c54f31913ce3ca2c2e564 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 2 Sep 2022 19:36:49 -0600 Subject: [PATCH 37/82] Pass user and members via props --- web/components/groups/groups-button.tsx | 21 +++++++++++++-------- web/pages/group/[...slugs]/index.tsx | 7 ++++++- web/pages/groups.tsx | 20 +++++++++++++++++--- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index 810a70bc..f60ed0af 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -72,29 +72,34 @@ function GroupsList(props: { groups: Group[] }) { function GroupItem(props: { group: Group; className?: string }) { const { group, className } = props + const user = useUser() + const memberIds = useMemberIds(group.id) return ( <Row className={clsx('items-center justify-between gap-2 p-2', className)}> <Row className="line-clamp-1 items-center gap-2"> <GroupLinkItem group={group} /> </Row> - <JoinOrLeaveGroupButton group={group} /> + <JoinOrLeaveGroupButton + group={group} + user={user} + isMember={user ? memberIds?.includes(user.id) : false} + /> </Row> ) } export function JoinOrLeaveGroupButton(props: { group: Group + isMember: boolean + user: User | undefined | null small?: boolean className?: string }) { - const { group, small, className } = props - const currentUser = useUser() - const memberIds = useMemberIds(group.id) - const isMember = memberIds?.includes(currentUser?.id ?? '') ?? false + const { group, small, className, isMember, user } = props const smallStyle = 'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500' - if (!currentUser) { + if (!user) { if (!group.anyoneCanJoin) return <div className={clsx(className, 'text-gray-500')}>Closed</div> return ( @@ -107,12 +112,12 @@ export function JoinOrLeaveGroupButton(props: { ) } const onJoinGroup = () => { - joinGroup(group, currentUser.id).catch(() => { + joinGroup(group, user.id).catch(() => { toast.error('Failed to join group') }) } const onLeaveGroup = () => { - leaveGroup(group, currentUser.id).catch(() => { + leaveGroup(group, user.id).catch(() => { toast.error('Failed to leave group') }) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 4626aa77..b4046c4c 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -331,6 +331,7 @@ function GroupOverview(props: { const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( group.slug )}${postFix}` + const isMember = user ? members.map((m) => m.id).includes(user.id) : false return ( <> @@ -349,7 +350,11 @@ function GroupOverview(props: { ) : ( user && ( <Row> - <JoinOrLeaveGroupButton group={group} /> + <JoinOrLeaveGroupButton + group={group} + user={user} + isMember={isMember} + /> </Row> ) )} diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 92a813aa..0afdaba5 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -134,6 +134,8 @@ export default function Groups(props: { key={group.id} group={group} creator={creatorsDict[group.creatorId]} + user={user} + isMember={memberGroupIds.includes(group.id)} /> ))} </div> @@ -159,6 +161,8 @@ export default function Groups(props: { key={group.id} group={group} creator={creatorsDict[group.creatorId]} + user={user} + isMember={memberGroupIds.includes(group.id)} /> ))} </div> @@ -173,8 +177,13 @@ export default function Groups(props: { ) } -export function GroupCard(props: { group: Group; creator: User | undefined }) { - const { group, creator } = props +export function GroupCard(props: { + group: Group + creator: User | undefined + user: User | undefined | null + isMember: boolean +}) { + const { group, creator, user, isMember } = props const { totalContracts } = group return ( <Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100"> @@ -201,7 +210,12 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { <div className="text-sm text-gray-500">{group.about}</div> </Row> <Col className={'mt-2 h-full items-start justify-end'}> - <JoinOrLeaveGroupButton group={group} className={'z-10 w-24'} /> + <JoinOrLeaveGroupButton + group={group} + className={'z-10 w-24'} + user={user} + isMember={isMember} + /> </Col> </Col> ) From 25a0276bf73f17873e2ee7fd7c17a2bd2f5f7772 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 2 Sep 2022 19:52:38 -0600 Subject: [PATCH 38/82] Auth user server-side on groups page --- web/pages/groups.tsx | 89 ++++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 0afdaba5..2bac5aed 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -8,7 +8,6 @@ import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { useGroups, useMemberGroupIds } from 'web/hooks/use-group' -import { useUser } from 'web/hooks/use-user' import { groupPath, listAllGroups } from 'web/lib/firebase/groups' import { getUser, User } from 'web/lib/firebase/users' import { Tabs } from 'web/components/layout/tabs' @@ -18,14 +17,15 @@ import { Avatar } from 'web/components/avatar' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { searchInAny } from 'common/util/parse' import { SEO } from 'web/components/SEO' +import { GetServerSideProps } from 'next' +import { authenticateOnServer } from 'web/lib/firebase/server-auth' +import { useUser } from 'web/hooks/use-user' -export async function getStaticProps() { +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const creds = await authenticateOnServer(ctx) + const serverUser = creds ? await getUser(creds.uid) : null const groups = await listAllGroups().catch((_) => []) - // mqp: temporary fix to make dev deploy while Ian works on migrating groups away - // from the document array member and contracts representation - // groups = groups.filter((g) => g.contractIds != null && g.memberIds != null) - const creators = await Promise.all( groups.map((group) => getUser(group.creatorId)) ) @@ -33,25 +33,20 @@ export async function getStaticProps() { creators.map((creator) => [creator.id, creator]) ) - return { - props: { - groups: groups, - creatorsDict, - }, - - revalidate: 60, // regenerate after a minute - } + return { props: { serverUser, groups: groups, creatorsDict } } } export default function Groups(props: { + serverUser: User | null groups: Group[] creatorsDict: { [k: string]: User } }) { + //TODO: do we really need the creatorsDict? const [creatorsDict, setCreatorsDict] = useState(props.creatorsDict) - + const { serverUser } = props || {} const groups = useGroups() ?? props.groups - const user = useUser() - const memberGroupIds = useMemberGroupIds(user) || [] + const memberGroupIds = useMemberGroupIds(serverUser) || [] + const user = useUser() ?? serverUser useEffect(() => { // Load User object for creator of new Groups. @@ -117,6 +112,39 @@ export default function Groups(props: { <Tabs currentPageForAnalytics={'groups'} tabs={[ + ...(user + ? [ + { + title: 'My Groups', + content: ( + <Col> + <input + type="text" + onChange={(e) => debouncedQuery(e.target.value)} + placeholder="Search your groups" + className="input input-bordered mb-4 w-full" + /> + + <div className="flex flex-wrap justify-center gap-4"> + {matchesOrderedByRecentActivity + .filter((match) => + memberGroupIds.includes(match.id) + ) + .map((group) => ( + <GroupCard + key={group.id} + group={group} + creator={creatorsDict[group.creatorId]} + user={user} + isMember={memberGroupIds.includes(group.id)} + /> + ))} + </div> + </Col> + ), + }, + ] + : []), { title: 'All', content: ( @@ -142,33 +170,6 @@ export default function Groups(props: { </Col> ), }, - { - title: 'My Groups', - content: ( - <Col> - <input - type="text" - onChange={(e) => debouncedQuery(e.target.value)} - placeholder="Search your groups" - className="input input-bordered mb-4 w-full" - /> - - <div className="flex flex-wrap justify-center gap-4"> - {matchesOrderedByRecentActivity - .filter((match) => memberGroupIds.includes(match.id)) - .map((group) => ( - <GroupCard - key={group.id} - group={group} - creator={creatorsDict[group.creatorId]} - user={user} - isMember={memberGroupIds.includes(group.id)} - /> - ))} - </div> - </Col> - ), - }, ]} /> </Col> From e924061c543c61e9eaf01fa72736b08141bdfe0a Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 19:39:07 -0700 Subject: [PATCH 39/82] Don't re-create visibility observer for no reason (#849) * Don't re-create visibility observer for no reason * `IntersectionObserver.unobserve` instead of `.disconnect` --- web/components/visibility-observer.tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/web/components/visibility-observer.tsx b/web/components/visibility-observer.tsx index 9af410c7..aea2e41d 100644 --- a/web/components/visibility-observer.tsx +++ b/web/components/visibility-observer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useEvent } from '../hooks/use-event' export function VisibilityObserver(props: { @@ -8,17 +8,18 @@ export function VisibilityObserver(props: { const { className } = props const [elem, setElem] = useState<HTMLElement | null>(null) const onVisibilityUpdated = useEvent(props.onVisibilityUpdated) - - useEffect(() => { - const hasIOSupport = !!window.IntersectionObserver - if (!hasIOSupport || !elem) return - - const observer = new IntersectionObserver(([entry]) => { + const observer = useRef( + new IntersectionObserver(([entry]) => { onVisibilityUpdated(entry.isIntersecting) }, {}) - observer.observe(elem) - return () => observer.disconnect() - }, [elem, onVisibilityUpdated]) + ).current + + useEffect(() => { + if (elem) { + observer.observe(elem) + return () => observer.unobserve(elem) + } + }, [elem, observer]) return <div ref={setElem} className={className}></div> } From 8318621d51c75fc328770414ea07ad98e8bfc084 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 19:39:27 -0700 Subject: [PATCH 40/82] Some changes to make auth better (#846) * Handle the case where a user is surprisingly not in the DB * Only set referral info on user after creation * More reliably cache current user info in local storage * Don't jam username stuff into user listener hook --- web/components/auth-context.tsx | 47 ++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index 0e9fbd0e..d7c7b717 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -67,6 +67,16 @@ export function AuthProvider(props: { } }, [setAuthUser, serverUser]) + useEffect(() => { + if (authUser != null) { + // Persist to local storage, to reduce login blink next time. + // Note: Cap on localStorage size is ~5mb + localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser)) + } else { + localStorage.removeItem(CACHED_USER_KEY) + } + }, [authUser]) + useEffect(() => { return onIdTokenChanged( auth, @@ -77,17 +87,13 @@ export function AuthProvider(props: { if (!current.user || !current.privateUser) { const deviceToken = ensureDeviceToken() current = (await createUser({ deviceToken })) as UserAndPrivateUser + setCachedReferralInfoForUser(current.user) } setAuthUser(current) - // Persist to local storage, to reduce login blink next time. - // Note: Cap on localStorage size is ~5mb - localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current)) - setCachedReferralInfoForUser(current.user) } else { // User logged out; reset to null setUserCookie(undefined) setAuthUser(null) - localStorage.removeItem(CACHED_USER_KEY) } }, (e) => { @@ -97,29 +103,32 @@ export function AuthProvider(props: { }, [setAuthUser]) const uid = authUser?.user.id - const username = authUser?.user.username useEffect(() => { - if (uid && username) { + if (uid) { identifyUser(uid) - setUserProperty('username', username) - const userListener = listenForUser(uid, (user) => - setAuthUser((authUser) => { - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - return { ...authUser!, user: user! } - }) - ) + const userListener = listenForUser(uid, (user) => { + setAuthUser((currAuthUser) => + currAuthUser && user ? { ...currAuthUser, user } : null + ) + }) const privateUserListener = listenForPrivateUser(uid, (privateUser) => { - setAuthUser((authUser) => { - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - return { ...authUser!, privateUser: privateUser! } - }) + setAuthUser((currAuthUser) => + currAuthUser && privateUser ? { ...currAuthUser, privateUser } : null + ) }) return () => { userListener() privateUserListener() } } - }, [uid, username, setAuthUser]) + }, [uid, setAuthUser]) + + const username = authUser?.user.username + useEffect(() => { + if (username != null) { + setUserProperty('username', username) + } + }, [username]) return ( <AuthContext.Provider value={authUser}>{children}</AuthContext.Provider> From 784c081663784c9357924f33dcf9f10c93941784 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 19:43:22 -0700 Subject: [PATCH 41/82] Enable source maps in production (#852) --- web/next.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/web/next.config.js b/web/next.config.js index 6ade8674..e99a3081 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -4,6 +4,7 @@ const ABOUT_PAGE_URL = 'https://docs.manifold.markets/$how-to' /** @type {import('next').NextConfig} */ module.exports = { + productionBrowserSourceMaps: true, staticPageGenerationTimeout: 600, // e.g. stats page reactStrictMode: true, optimizeFonts: false, From bfa88c3406b0a641f37b15c533d37f64c8849121 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 2 Sep 2022 22:51:33 -0500 Subject: [PATCH 42/82] Turn off react-query notification subscription because it's buggy --- web/hooks/use-notifications.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 60d0e43e..473facd4 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -16,11 +16,7 @@ export type NotificationGroup = { function useNotifications(privateUser: PrivateUser) { const result = useFirestoreQueryData( ['notifications-all', privateUser.id], - getNotificationsQuery(privateUser.id), - { subscribe: true, includeMetadataChanges: true }, - // Temporary workaround for react-query bug: - // https://github.com/invertase/react-query-firebase/issues/25 - { refetchOnMount: 'always' } + getNotificationsQuery(privateUser.id) ) const notifications = useMemo(() => { From 2d88675f42ac7384a3634cf5d2c5dc4634910f04 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Sat, 3 Sep 2022 06:33:33 -0600 Subject: [PATCH 43/82] Move & more out of the loop --- web/components/multi-user-transaction-link.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/web/components/multi-user-transaction-link.tsx b/web/components/multi-user-transaction-link.tsx index 70d273db..3f44349f 100644 --- a/web/components/multi-user-transaction-link.tsx +++ b/web/components/multi-user-transaction-link.tsx @@ -32,20 +32,21 @@ export function MultiUserTransactionLink(props: { setOpen(true) }} > - <Row className={'gap-1'}> - {userInfos.map((userInfo, index) => - index < maxShowCount ? ( - <Row key={userInfo.username + 'shortened'}> + <Row className={'items-center gap-1 sm:gap-2'}> + {userInfos.map( + (userInfo, index) => + index < maxShowCount && ( <Avatar username={userInfo.username} size={'sm'} avatarUrl={userInfo.avatarUrl} noLink={userInfos.length > 1} + key={userInfo.username + 'avatar'} /> - </Row> - ) : ( - <span>& {userInfos.length - maxShowCount} more</span> - ) + ) + )} + {userInfos.length > maxShowCount && ( + <span>& {userInfos.length - maxShowCount} more</span> )} </Row> </Button> From 861fb7abbd5a6a0a4ab09a2465d7e62cee879478 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 3 Sep 2022 05:51:55 -0700 Subject: [PATCH 44/82] Use the magic `auth` prop for groups SSR (#851) --- web/pages/groups.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 2bac5aed..100c8a54 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -9,7 +9,7 @@ import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { useGroups, useMemberGroupIds } from 'web/hooks/use-group' import { groupPath, listAllGroups } from 'web/lib/firebase/groups' -import { getUser, User } from 'web/lib/firebase/users' +import { getUser, getUserAndPrivateUser, User } from 'web/lib/firebase/users' import { Tabs } from 'web/components/layout/tabs' import { SiteLink } from 'web/components/site-link' import clsx from 'clsx' @@ -23,7 +23,7 @@ import { useUser } from 'web/hooks/use-user' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) - const serverUser = creds ? await getUser(creds.uid) : null + const auth = creds ? await getUserAndPrivateUser(creds.uid) : null const groups = await listAllGroups().catch((_) => []) const creators = await Promise.all( @@ -33,17 +33,17 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { creators.map((creator) => [creator.id, creator]) ) - return { props: { serverUser, groups: groups, creatorsDict } } + return { props: { auth, groups, creatorsDict } } } export default function Groups(props: { - serverUser: User | null + auth: { user: User } | null groups: Group[] creatorsDict: { [k: string]: User } }) { //TODO: do we really need the creatorsDict? const [creatorsDict, setCreatorsDict] = useState(props.creatorsDict) - const { serverUser } = props || {} + const serverUser = props.auth?.user const groups = useGroups() ?? props.groups const memberGroupIds = useMemberGroupIds(serverUser) || [] const user = useUser() ?? serverUser From 272658e5dc3e26185be00f322a1d0b2fa37b389f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Sat, 3 Sep 2022 06:52:51 -0600 Subject: [PATCH 45/82] Use most up-to-date user on groups page --- web/pages/groups.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 100c8a54..3405ef3e 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -45,8 +45,8 @@ export default function Groups(props: { const [creatorsDict, setCreatorsDict] = useState(props.creatorsDict) const serverUser = props.auth?.user const groups = useGroups() ?? props.groups - const memberGroupIds = useMemberGroupIds(serverUser) || [] const user = useUser() ?? serverUser + const memberGroupIds = useMemberGroupIds(user) || [] useEffect(() => { // Load User object for creator of new Groups. From 0938368e3065abcd7fbf8e72a4677b4e3420e6ae Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Sat, 3 Sep 2022 07:29:35 -0600 Subject: [PATCH 46/82] Capitalize yes/no resolution outcomes --- og-image/api/_lib/template.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index 26f7677e..2469a636 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -22,13 +22,13 @@ export function getHtml(parsedReq: ParsedRequest) { const hideAvatar = creatorAvatarUrl ? '' : 'hidden' let resolutionColor = 'text-primary' - let resolutionString = 'Yes' + let resolutionString = 'YES' switch (resolution) { case 'YES': break case 'NO': resolutionColor = 'text-red-500' - resolutionString = 'No' + resolutionString = 'NO' break case 'CANCEL': resolutionColor = 'text-yellow-500' From c0383bcf26832fbd2498358f457d69ee3cfaae67 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 3 Sep 2022 09:55:10 -0700 Subject: [PATCH 47/82] Make tournament page efficient (#832) * Make tournament page efficient * Fix URL to Salem contract * Use totalMembers instead of deprecated field * Increase page size to 12 Co-authored-by: Austin Chen <akrolsmir@gmail.com> --- web/components/carousel.tsx | 2 +- web/hooks/use-pagination.ts | 1 + web/lib/firebase/contracts.ts | 11 +- web/pages/tournaments/index.tsx | 243 +++++++++++++++++--------------- 4 files changed, 137 insertions(+), 120 deletions(-) diff --git a/web/components/carousel.tsx b/web/components/carousel.tsx index 9719ba06..79baa451 100644 --- a/web/components/carousel.tsx +++ b/web/components/carousel.tsx @@ -33,7 +33,7 @@ export function Carousel(props: { }, 500) // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(onScroll, []) + useEffect(onScroll, [children]) return ( <div className={clsx('relative', className)}> diff --git a/web/hooks/use-pagination.ts b/web/hooks/use-pagination.ts index 485afca8..ab991d1f 100644 --- a/web/hooks/use-pagination.ts +++ b/web/hooks/use-pagination.ts @@ -103,6 +103,7 @@ export const usePagination = <T>(opts: PaginationOptions<T>) => { isEnd: state.isComplete && state.pageEnd >= state.docs.length, getPrev: () => dispatch({ type: 'PREV' }), getNext: () => dispatch({ type: 'NEXT' }), + allItems: () => state.docs.map((d) => d.data()), getItems: () => state.docs.slice(state.pageStart, state.pageEnd).map((d) => d.data()), } diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index c7e32f71..5c65b23f 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -104,11 +104,18 @@ export async function listContracts(creatorId: string): Promise<Contract[]> { return snapshot.docs.map((doc) => doc.data()) } +export const contractsByGroupSlugQuery = (slug: string) => + query( + contracts, + where('groupSlugs', 'array-contains', slug), + where('isResolved', '==', false), + orderBy('popularityScore', 'desc') + ) + export async function listContractsByGroupSlug( slug: string ): Promise<Contract[]> { - const q = query(contracts, where('groupSlugs', 'array-contains', slug)) - const snapshot = await getDocs(q) + const snapshot = await getDocs(contractsByGroupSlugQuery(slug)) return snapshot.docs.map((doc) => doc.data()) } diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index 9bfdfb89..c9827f72 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -1,16 +1,10 @@ import { ClockIcon } from '@heroicons/react/outline' import { UsersIcon } from '@heroicons/react/solid' -import { - BinaryContract, - Contract, - PseudoNumericContract, -} from 'common/contract' -import { Group } from 'common/group' -import dayjs, { Dayjs } from 'dayjs' +import dayjs from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' -import { keyBy, mapValues, sortBy } from 'lodash' +import { zip } from 'lodash' import Image, { ImageProps, StaticImageData } from 'next/image' import Link from 'next/link' import { useState } from 'react' @@ -20,27 +14,33 @@ import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { SEO } from 'web/components/SEO' -import { listContractsByGroupSlug } from 'web/lib/firebase/contracts' +import { contractsByGroupSlugQuery } from 'web/lib/firebase/contracts' import { getGroup, groupPath } from 'web/lib/firebase/groups' import elon_pic from './_cspi/Will_Elon_Buy_Twitter.png' import china_pic from './_cspi/Chinese_Military_Action_against_Taiwan.png' import mpox_pic from './_cspi/Monkeypox_Cases.png' import race_pic from './_cspi/Supreme_Court_Ban_Race_in_College_Admissions.png' import { SiteLink } from 'web/components/site-link' -import { getProbability } from 'common/calculate' import { Carousel } from 'web/components/carousel' +import { usePagination } from 'web/hooks/use-pagination' +import { LoadingIndicator } from 'web/components/loading-indicator' dayjs.extend(utc) dayjs.extend(timezone) dayjs.extend(customParseFormat) -const toDate = (d: string) => dayjs(d, 'MMM D, YYYY').tz('America/Los_Angeles') +const toDate = (d: string) => + dayjs(d, 'MMM D, YYYY').tz('America/Los_Angeles').valueOf() + +type MarketImage = { + marketUrl: string + image: StaticImageData +} type Tourney = { title: string - url?: string blurb: string // actual description in the click-through award?: string - endTime?: Dayjs + endTime?: number groupId: string } @@ -50,7 +50,7 @@ const Salem = { url: 'https://salemcenter.manifold.markets/', award: '$25,000', endTime: toDate('Jul 31, 2023'), - markets: [], + contractIds: [], images: [ { marketUrl: @@ -107,33 +107,27 @@ const tourneys: Tourney[] = [ // }, ] -export async function getStaticProps() { - const groupIds = tourneys - .map((data) => data.groupId) - .filter((id) => id != undefined) as string[] - const groups = (await Promise.all(groupIds.map(getGroup))) - // Then remove undefined groups - .filter(Boolean) as Group[] - - const contracts = await Promise.all( - groups.map((g) => listContractsByGroupSlug(g?.slug ?? '')) - ) - - const markets = Object.fromEntries(groups.map((g, i) => [g.id, contracts[i]])) - - const groupMap = keyBy(groups, 'id') - const numPeople = mapValues(groupMap, (g) => g?.totalMembers) - const slugs = mapValues(groupMap, 'slug') - - return { props: { markets, numPeople, slugs }, revalidate: 60 * 10 } +type SectionInfo = { + tourney: Tourney + slug: string + numPeople: number } -export default function TournamentPage(props: { - markets: { [groupId: string]: Contract[] } - numPeople: { [groupId: string]: number } - slugs: { [groupId: string]: string } -}) { - const { markets = {}, numPeople = {}, slugs = {} } = props +export async function getStaticProps() { + const groupIds = tourneys.map((data) => data.groupId) + const groups = await Promise.all(groupIds.map(getGroup)) + const sections = zip(tourneys, groups) + .filter(([_tourney, group]) => group != null) + .map(([tourney, group]) => ({ + tourney, + slug: group!.slug, // eslint-disable-line + numPeople: group!.totalMembers, // eslint-disable-line + })) + return { props: { sections } } +} + +export default function TournamentPage(props: { sections: SectionInfo[] }) { + const { sections } = props return ( <Page> @@ -141,96 +135,111 @@ export default function TournamentPage(props: { title="Tournaments" description="Win money by betting in forecasting touraments on current events, sports, science, and more" /> - <Col className="mx-4 mt-4 gap-20 sm:mx-10 xl:w-[125%]"> - {tourneys.map(({ groupId, ...data }) => ( - <Section - key={groupId} - {...data} - url={groupPath(slugs[groupId])} - ppl={numPeople[groupId] ?? 0} - markets={markets[groupId] ?? []} - /> + <Col className="mx-4 mt-4 gap-10 sm:mx-10 xl:w-[125%]"> + {sections.map(({ tourney, slug, numPeople }) => ( + <div key={slug}> + <SectionHeader + url={groupPath(slug)} + title={tourney.title} + ppl={numPeople} + award={tourney.award} + endTime={tourney.endTime} + /> + <span>{tourney.blurb}</span> + <MarketCarousel slug={slug} /> + </div> ))} - <Section {...Salem} /> + <div> + <SectionHeader + url={Salem.url} + title={Salem.title} + award={Salem.award} + endTime={Salem.endTime} + /> + <span>{Salem.blurb}</span> + <ImageCarousel url={Salem.url} images={Salem.images} /> + </div> </Col> </Page> ) } -function Section(props: { - title: string +const SectionHeader = (props: { url: string - blurb: string - award?: string + title: string ppl?: number - endTime?: Dayjs - markets: Contract[] - images?: { marketUrl: string; image: StaticImageData }[] // hack for cspi -}) { - const { title, url, blurb, award, ppl, endTime, images } = props - // Sort markets by probability, highest % first - const markets = sortBy(props.markets, (c) => - getProbability(c as BinaryContract | PseudoNumericContract) - ) - .reverse() - .filter((c) => !c.isResolved) - + award?: string + endTime?: number +}) => { + const { url, title, ppl, award, endTime } = props return ( - <div> - <Link href={url}> - <a className="group mb-3 flex flex-wrap justify-between"> - <h2 className="text-xl font-semibold group-hover:underline md:text-3xl"> - {title} - </h2> - <Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6"> - {!!award && <span className="flex items-center">🏆 {award}</span>} - {!!ppl && ( + <Link href={url}> + <a className="group mb-3 flex flex-wrap justify-between"> + <h2 className="text-xl font-semibold group-hover:underline md:text-3xl"> + {title} + </h2> + <Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6"> + {!!award && <span className="flex items-center">🏆 {award}</span>} + {!!ppl && ( + <span className="flex items-center gap-1"> + <UsersIcon className="h-4" /> + {ppl} + </span> + )} + {endTime && ( + <DateTimeTooltip time={endTime} text="Ends"> <span className="flex items-center gap-1"> - <UsersIcon className="h-4" /> - {ppl} + <ClockIcon className="h-4" /> + {dayjs(endTime).format('MMM D')} </span> - )} - {endTime && ( - <DateTimeTooltip time={endTime.valueOf()} text="Ends"> - <span className="flex items-center gap-1"> - <ClockIcon className="h-4" /> - {endTime.format('MMM D')} - </span> - </DateTimeTooltip> - )} - </Row> + </DateTimeTooltip> + )} + </Row> + </a> + </Link> + ) +} + +const ImageCarousel = (props: { images: MarketImage[]; url: string }) => { + const { images, url } = props + return ( + <Carousel className="-mx-4 mt-4 sm:-mx-10"> + <div className="shrink-0 sm:w-6" /> + {images.map(({ marketUrl, image }) => ( + <a key={marketUrl} href={marketUrl} className="hover:brightness-95"> + <NaturalImage src={image} /> </a> - </Link> - <span>{blurb}</span> - <Carousel className="-mx-4 mt-2 sm:-mx-10"> - <div className="shrink-0 sm:w-6" /> - {markets.length ? ( - markets.map((m) => ( - <ContractCard - contract={m} - hideGroupLink - className="mb-2 max-h-[200px] w-96 shrink-0" - questionClass="line-clamp-3" - trackingPostfix=" tournament" - /> - )) - ) : ( - <> - {images?.map(({ marketUrl, image }) => ( - <a href={marketUrl} className="hover:brightness-95"> - <NaturalImage src={image} /> - </a> - ))} - <SiteLink - className="ml-6 mr-10 flex shrink-0 items-center text-indigo-700" - href={url} - > - See more - </SiteLink> - </> - )} - </Carousel> - </div> + ))} + <SiteLink + className="ml-6 mr-10 flex shrink-0 items-center text-indigo-700" + href={url} + > + See more + </SiteLink> + </Carousel> + ) +} + +const MarketCarousel = (props: { slug: string }) => { + const { slug } = props + const q = contractsByGroupSlugQuery(slug) + const { allItems, getNext, isLoading } = usePagination({ q, pageSize: 12 }) + return isLoading ? ( + <LoadingIndicator className="mt-10" /> + ) : ( + <Carousel className="-mx-4 mt-4 sm:-mx-10" loadMore={getNext}> + <div className="shrink-0 sm:w-6" /> + {allItems().map((m) => ( + <ContractCard + key={m.id} + contract={m} + hideGroupLink + className="mb-2 max-h-[200px] w-96 shrink-0" + questionClass="line-clamp-3" + trackingPostfix=" tournament" + /> + ))} + </Carousel> ) } From 085b9aeb2a7d50f70dd8842def8bcf41d388e450 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 3 Sep 2022 14:55:37 -0500 Subject: [PATCH 48/82] remove simulator --- web/lib/simulator/entries.ts | 73 ------- web/lib/simulator/sample-bids.ts | 58 ------ web/pages/simulator.tsx | 332 ------------------------------- 3 files changed, 463 deletions(-) delete mode 100644 web/lib/simulator/entries.ts delete mode 100644 web/lib/simulator/sample-bids.ts delete mode 100644 web/pages/simulator.tsx diff --git a/web/lib/simulator/entries.ts b/web/lib/simulator/entries.ts deleted file mode 100644 index 535a59ad..00000000 --- a/web/lib/simulator/entries.ts +++ /dev/null @@ -1,73 +0,0 @@ -type Bid = { yesBid: number; noBid: number } - -// An entry has a yes/no for bid, weight, payout, return. Also a current probability -export type Entry = { - yesBid: number - noBid: number - yesWeight: number - noWeight: number - yesPayout: number - noPayout: number - yesReturn: number - noReturn: number - prob: number -} - -function makeWeights(bids: Bid[]) { - const weights = [] - let yesPot = 0 - let noPot = 0 - - // First pass: calculate all the weights - for (const { yesBid, noBid } of bids) { - const yesWeight = - yesBid + - (yesBid * Math.pow(noPot, 2)) / - (Math.pow(yesPot, 2) + yesBid * yesPot) || 0 - const noWeight = - noBid + - (noBid * Math.pow(yesPot, 2)) / (Math.pow(noPot, 2) + noBid * noPot) || - 0 - - // Note: Need to calculate weights BEFORE updating pot - yesPot += yesBid - noPot += noBid - const prob = - Math.pow(yesPot, 2) / (Math.pow(yesPot, 2) + Math.pow(noPot, 2)) - - weights.push({ - yesBid, - noBid, - yesWeight, - noWeight, - prob, - }) - } - return weights -} - -export function makeEntries(bids: Bid[]): Entry[] { - const YES_SEED = bids[0].yesBid - const NO_SEED = bids[0].noBid - - const weights = makeWeights(bids) - const yesPot = weights.reduce((sum, { yesBid }) => sum + yesBid, 0) - const noPot = weights.reduce((sum, { noBid }) => sum + noBid, 0) - const yesWeightsSum = weights.reduce((sum, entry) => sum + entry.yesWeight, 0) - const noWeightsSum = weights.reduce((sum, entry) => sum + entry.noWeight, 0) - - const potSize = yesPot + noPot - YES_SEED - NO_SEED - - // Second pass: calculate all the payouts - const entries: Entry[] = [] - - for (const weight of weights) { - const { yesBid, noBid, yesWeight, noWeight } = weight - const yesPayout = (yesWeight / yesWeightsSum) * potSize - const noPayout = (noWeight / noWeightsSum) * potSize - const yesReturn = (yesPayout - yesBid) / yesBid - const noReturn = (noPayout - noBid) / noBid - entries.push({ ...weight, yesPayout, noPayout, yesReturn, noReturn }) - } - return entries -} diff --git a/web/lib/simulator/sample-bids.ts b/web/lib/simulator/sample-bids.ts deleted file mode 100644 index 547e6dce..00000000 --- a/web/lib/simulator/sample-bids.ts +++ /dev/null @@ -1,58 +0,0 @@ -const data = `1,9 -8, -,1 -1, -,1 -1, -,5 -5, -,5 -5, -,1 -1, -100, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10,` - -// Parse data into Yes/No orders -// E.g. `8,\n,1\n1,` => -// [{yesBid: 8, noBid: 0}, {yesBid: 0, noBid: 1}, {yesBid: 1, noBid: 0}] -export const bids = data.split('\n').map((line) => { - const [yesBid, noBid] = line.split(',') - return { - yesBid: parseInt(yesBid || '0'), - noBid: parseInt(noBid || '0'), - } -}) diff --git a/web/pages/simulator.tsx b/web/pages/simulator.tsx deleted file mode 100644 index 756e483b..00000000 --- a/web/pages/simulator.tsx +++ /dev/null @@ -1,332 +0,0 @@ -import React, { useMemo, useState } from 'react' -import { DatumValue } from '@nivo/core' -import { ResponsiveLine } from '@nivo/line' - -import { Entry, makeEntries } from 'web/lib/simulator/entries' -import { Col } from 'web/components/layout/col' - -function TableBody(props: { entries: Entry[] }) { - return ( - <tbody> - {props.entries.map((entry, i) => ( - <tr key={i}> - <th>{props.entries.length - i}</th> - <TableRowStart entry={entry} /> - <TableRowEnd entry={entry} /> - </tr> - ))} - </tbody> - ) -} - -function TableRowStart(props: { entry: Entry }) { - const { entry } = props - if (entry.yesBid && entry.noBid) { - return ( - <> - <td> - <div className="badge">ANTE</div> - </td> - <td> - ${entry.yesBid} / ${entry.noBid} - </td> - </> - ) - } else if (entry.yesBid) { - return ( - <> - <td> - <div className="badge badge-success">YES</div> - </td> - <td>${entry.yesBid}</td> - </> - ) - } else { - return ( - <> - <td> - <div className="badge badge-error">NO</div> - </td> - <td>${entry.noBid}</td> - </> - ) - } -} - -function TableRowEnd(props: { entry: Entry | null; isNew?: boolean }) { - const { entry } = props - if (!entry) { - return ( - <> - <td>0</td> - <td>0</td> - {!props.isNew && ( - <> - <td>N/A</td> - <td>N/A</td> - </> - )} - </> - ) - } else if (entry.yesBid && entry.noBid) { - return ( - <> - <td>{(entry.prob * 100).toFixed(1)}%</td> - <td>N/A</td> - {!props.isNew && ( - <> - <td>N/A</td> - <td>N/A</td> - </> - )} - </> - ) - } else if (entry.yesBid) { - return ( - <> - <td>{(entry.prob * 100).toFixed(1)}%</td> - <td>${entry.yesWeight.toFixed(0)}</td> - {!props.isNew && ( - <> - <td>${entry.yesPayout.toFixed(0)}</td> - <td>{(entry.yesReturn * 100).toFixed(0)}%</td> - </> - )} - </> - ) - } else { - return ( - <> - <td>{(entry.prob * 100).toFixed(1)}%</td> - <td>${entry.noWeight.toFixed(0)}</td> - {!props.isNew && ( - <> - <td>${entry.noPayout.toFixed(0)}</td> - <td>{(entry.noReturn * 100).toFixed(0)}%</td> - </> - )} - </> - ) - } -} - -type Bid = { yesBid: number; noBid: number } - -function NewBidTable(props: { - steps: number - bids: Array<Bid> - setSteps: (steps: number) => void - setBids: (bids: Array<Bid>) => void -}) { - const { steps, bids, setSteps, setBids } = props - // Prepare for new bids - const [newBid, setNewBid] = useState(0) - const [newBidType, setNewBidType] = useState('YES') - - function makeBid(type: string, bid: number) { - return { - yesBid: type == 'YES' ? bid : 0, - noBid: type == 'YES' ? 0 : bid, - } - } - - function submitBid() { - if (newBid <= 0) return - const bid = makeBid(newBidType, newBid) - bids.splice(steps, 0, bid) - setBids(bids) - setSteps(steps + 1) - setNewBid(0) - } - - function toggleBidType() { - setNewBidType(newBidType === 'YES' ? 'NO' : 'YES') - } - - const nextBid = makeBid(newBidType, newBid) - const fakeBids = [...bids.slice(0, steps), nextBid] - const entries = makeEntries(fakeBids) - const nextEntry = entries[entries.length - 1] - - function randomBid() { - const bidType = Math.random() < 0.5 ? 'YES' : 'NO' - // const p = bidType === 'YES' ? nextEntry.prob : 1 - nextEntry.prob - - const amount = Math.floor(Math.random() * 300) + 1 - const bid = makeBid(bidType, amount) - - bids.splice(steps, 0, bid) - setBids(bids) - setSteps(steps + 1) - setNewBid(0) - } - - return ( - <> - <table className="table-compact my-8 table w-full text-center"> - <thead> - <tr> - <th>Order #</th> - <th>Type</th> - <th>Bet</th> - <th>Prob</th> - <th>Est Payout</th> - <th></th> - </tr> - </thead> - <tbody> - <tr> - <th>{steps + 1}</th> - <td> - <div - className={ - `badge hover:cursor-pointer ` + - (newBidType == 'YES' ? 'badge-success' : 'badge-ghost') - } - onClick={toggleBidType} - > - YES - </div> - <br /> - <div - className={ - `badge hover:cursor-pointer ` + - (newBidType == 'NO' ? 'badge-error' : 'badge-ghost') - } - onClick={toggleBidType} - > - NO - </div> - </td> - <td> - {/* Note: Would love to make this input smaller... */} - <input - type="number" - placeholder="0" - className="input input-bordered max-w-[100px]" - value={newBid.toString()} - onChange={(e) => setNewBid(parseInt(e.target.value) || 0)} - onKeyUp={(e) => { - if (e.key === 'Enter') { - submitBid() - } - }} - onFocus={(e) => e.target.select()} - /> - </td> - - <TableRowEnd entry={nextEntry} isNew /> - - <button - className="btn btn-primary mt-2" - onClick={() => submitBid()} - disabled={newBid <= 0} - > - Submit - </button> - </tr> - </tbody> - </table> - - <button className="btn btn-secondary mb-4" onClick={randomBid}> - Random bet! - </button> - </> - ) -} - -// Show a hello world React page -export default function Simulator() { - const [steps, setSteps] = useState(1) - const [bids, setBids] = useState([{ yesBid: 100, noBid: 100 }]) - - const entries = useMemo( - () => makeEntries(bids.slice(0, steps)), - [bids, steps] - ) - - const reversedEntries = [...entries].reverse() - - const probs = entries.map((entry) => entry.prob) - const points = probs.map((prob, i) => ({ x: i + 1, y: prob * 100 })) - const data = [{ id: 'Yes', data: points, color: '#11b981' }] - const tickValues = [0, 25, 50, 75, 100] - - return ( - <Col> - <div className="mx-auto mt-8 grid w-full grid-cols-1 gap-4 p-2 text-center xl:grid-cols-2"> - {/* Left column */} - <div> - <h1 className="mb-8 text-2xl font-bold"> - Dynamic Parimutuel Market Simulator - </h1> - - <NewBidTable {...{ steps, bids, setSteps, setBids }} /> - - {/* History of bids */} - <div className="overflow-x-auto"> - <table className="table w-full text-center"> - <thead> - <tr> - <th>Order #</th> - <th>Type</th> - <th>Bet</th> - <th>Prob</th> - <th>Est Payout</th> - <th>Payout</th> - <th>Return</th> - </tr> - </thead> - - <TableBody entries={reversedEntries} /> - </table> - </div> - </div> - - {/* Right column */} - <Col> - <h1 className="mb-8 text-2xl font-bold"> - Probability of - <div className="badge badge-success w-18 ml-3 h-8 text-2xl"> - YES - </div> - </h1> - <div className="mb-10 h-[500px] w-full"> - <ResponsiveLine - data={data} - yScale={{ min: 0, max: 100, type: 'linear' }} - yFormat={formatPercent} - gridYValues={tickValues} - axisLeft={{ - tickValues, - format: formatPercent, - }} - enableGridX={false} - colors={{ datum: 'color' }} - pointSize={8} - pointBorderWidth={1} - pointBorderColor="#fff" - enableSlices="x" - enableArea - margin={{ top: 20, right: 10, bottom: 20, left: 40 }} - /> - </div> - {/* Range slider that sets the current step */} - <label>Orders # 1 - {steps}</label> - <input - type="range" - className="range" - min="1" - max={bids.length} - value={steps} - onChange={(e) => setSteps(parseInt(e.target.value))} - /> - </Col> - </div> - </Col> - ) -} - -function formatPercent(y: DatumValue) { - return `${Math.round(+y.toString())}%` -} From 9060abde8ec41a6cd37faf71c7b9328928b94558 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 3 Sep 2022 15:06:41 -0500 Subject: [PATCH 49/82] Cache prob and prob changes on cpmm contracts --- common/calculate-metrics.ts | 29 ++++++++++++++++++++++++++++- common/contract.ts | 6 ++++++ common/new-contract.ts | 2 ++ functions/src/update-metrics.ts | 24 ++++++++++++++++++++++-- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index e3b8ea39..3aad1a9c 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -1,4 +1,4 @@ -import { sortBy, sum, sumBy } from 'lodash' +import { last, sortBy, sum, sumBy } from 'lodash' import { calculatePayout } from './calculate' import { Bet } from './bet' import { Contract } from './contract' @@ -36,6 +36,33 @@ export const computeVolume = (contractBets: Bet[], since: number) => { ) } +const calculateProbChangeSince = (descendingBets: Bet[], since: number) => { + const newestBet = descendingBets[0] + if (!newestBet) return 0 + + const betBeforeSince = descendingBets.find((b) => b.createdTime < since) + + if (!betBeforeSince) { + const oldestBet = last(descendingBets) ?? newestBet + return newestBet.probAfter - oldestBet.probBefore + } + + return newestBet.probAfter - betBeforeSince.probAfter +} + +export const calculateProbChanges = (descendingBets: Bet[]) => { + const now = Date.now() + const yesterday = now - DAY_MS + const weekAgo = now - 7 * DAY_MS + const monthAgo = now - 30 * DAY_MS + + return { + day: calculateProbChangeSince(descendingBets, yesterday), + week: calculateProbChangeSince(descendingBets, weekAgo), + month: calculateProbChangeSince(descendingBets, monthAgo), + } +} + export const calculateCreatorVolume = (userContracts: Contract[]) => { const allTimeCreatorVolume = computeTotalPool(userContracts, 0) const monthlyCreatorVolume = computeTotalPool( diff --git a/common/contract.ts b/common/contract.ts index 5dc4b696..0d2a38ca 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -87,6 +87,12 @@ export type CPMM = { pool: { [outcome: string]: number } p: number // probability constant in y^p * n^(1-p) = k totalLiquidity: number // in M$ + prob: number + probChanges: { + day: number + week: number + month: number + } } export type Binary = { diff --git a/common/new-contract.ts b/common/new-contract.ts index 17b872ab..431f435e 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -123,6 +123,8 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => { initialProbability: p, p, pool: pool, + prob: initialProb, + probChanges: { day: 0, week: 0, month: 0 }, } return system diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index c6673969..430f3d33 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,9 +1,9 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { groupBy, isEmpty, keyBy, last } from 'lodash' +import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' -import { Contract } from '../../common/contract' +import { Contract, CPMM } from '../../common/contract' import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' @@ -11,8 +11,10 @@ import { calculateCreatorVolume, calculateNewPortfolioMetrics, calculateNewProfit, + calculateProbChanges, computeVolume, } from '../../common/calculate-metrics' +import { getProbability } from '../../common/calculate' const firestore = admin.firestore() @@ -43,11 +45,29 @@ export async function updateMetricsCore() { .filter((contract) => contract.id) .map((contract) => { const contractBets = betsByContract[contract.id] ?? [] + const descendingBets = sortBy( + contractBets, + (bet) => bet.createdTime + ).reverse() + + let cpmmFields: Partial<CPMM> = {} + if (contract.mechanism === 'cpmm-1') { + const prob = descendingBets[0] + ? descendingBets[0].probAfter + : getProbability(contract) + + cpmmFields = { + prob, + probChanges: calculateProbChanges(descendingBets), + } + } + return { doc: firestore.collection('contracts').doc(contract.id), fields: { volume24Hours: computeVolume(contractBets, now - DAY_MS), volume7Days: computeVolume(contractBets, now - DAY_MS * 7), + ...cpmmFields, }, } }) From 89b30fc50d5b4dcf92c850dcc2c295b1679ae819 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 3 Sep 2022 14:07:34 -0700 Subject: [PATCH 50/82] Fix tournaments page loading indicator and turn page size back down --- web/pages/tournaments/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index c9827f72..1a74e8ea 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -223,13 +223,16 @@ const ImageCarousel = (props: { images: MarketImage[]; url: string }) => { const MarketCarousel = (props: { slug: string }) => { const { slug } = props const q = contractsByGroupSlugQuery(slug) - const { allItems, getNext, isLoading } = usePagination({ q, pageSize: 12 }) - return isLoading ? ( + const { allItems, getNext } = usePagination({ q, pageSize: 6 }) + const items = allItems() + + // todo: would be nice to have indicator somewhere when it loads next page + return items.length === 0 ? ( <LoadingIndicator className="mt-10" /> ) : ( <Carousel className="-mx-4 mt-4 sm:-mx-10" loadMore={getNext}> <div className="shrink-0 sm:w-6" /> - {allItems().map((m) => ( + {items.map((m) => ( <ContractCard key={m.id} contract={m} From a21466d877c522c4abee775cb8274fcc3411d2d3 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 3 Sep 2022 16:20:56 -0500 Subject: [PATCH 51/82] Add sort for 24 hour change in probability --- web/components/contract-search.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index a0396d2e..8ace85eb 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -43,6 +43,7 @@ export const SORTS = [ { label: 'Trending', value: 'score' }, { label: 'Most traded', value: 'most-traded' }, { label: '24h volume', value: '24-hour-vol' }, + { label: '24h change', value: 'prob-change-day' }, { label: 'Last updated', value: 'last-updated' }, { label: 'Subsidy', value: 'liquidity' }, { label: 'Close date', value: 'close-date' }, From a15230e7ab77b7b89a23132896fa6be64f5742ce Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 4 Sep 2022 14:06:29 -0500 Subject: [PATCH 52/82] Smartest money => Best bet. Don't show amount made for comment. --- web/components/contract/contract-leaderboard.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index cc253433..ce5c7da6 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -109,10 +109,6 @@ export function ContractTopTrades(props: { betsBySameUser={[betsById[topCommentId]]} /> </div> - <div className="mt-2 text-sm text-gray-500"> - {commentsById[topCommentId].userName} made{' '} - {formatMoney(profitById[topCommentId] || 0)}! - </div> <Spacer h={16} /> </> )} @@ -120,11 +116,11 @@ export function ContractTopTrades(props: { {/* If they're the same, only show the comment; otherwise show both */} {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && ( <> - <Title text="💸 Smartest money" className="!mt-0" /> + <Title text="💸 Best bet" className="!mt-0" /> <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> <FeedBet contract={contract} bet={betsById[topBetId]} /> </div> - <div className="mt-2 text-sm text-gray-500"> + <div className="mt-2 ml-2 text-sm text-gray-500"> {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! </div> </> From 6ef2beed8f0d9f1805ed71c6ddcff87819de05a6 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sun, 4 Sep 2022 14:28:45 -0700 Subject: [PATCH 53/82] Denormalize `betAmount` and `betOutcome` fields on comments (#838) * Create and use `betAmount` and `betOutcome` fields on comments * Be robust to ridiculous bet IDs on dev --- common/comment.ts | 10 ++- .../src/on-create-comment-on-contract.ts | 6 +- .../scripts/denormalize-comment-bet-data.ts | 69 +++++++++++++++++++ web/components/feed/feed-comments.tsx | 20 +++--- 4 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 functions/src/scripts/denormalize-comment-bet-data.ts diff --git a/common/comment.ts b/common/comment.ts index c7f9b855..3a4bd9ac 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -23,10 +23,16 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = { type OnContract = { commentType: 'contract' contractId: string - contractSlug: string - contractQuestion: string answerOutcome?: string betId?: string + + // denormalized from contract + contractSlug: string + contractQuestion: string + + // denormalized from bet + betAmount?: number + betOutcome?: string } type OnGroup = { diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 663a7977..a36a8bca 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -63,11 +63,15 @@ export const onCreateCommentOnContract = functions .doc(comment.betId) .get() bet = betSnapshot.data() as Bet - answer = contract.outcomeType === 'FREE_RESPONSE' && contract.answers ? contract.answers.find((answer) => answer.id === bet?.outcome) : undefined + + await change.ref.update({ + betOutcome: bet.outcome, + betAmount: bet.amount, + }) } const comments = await getValues<ContractComment>( diff --git a/functions/src/scripts/denormalize-comment-bet-data.ts b/functions/src/scripts/denormalize-comment-bet-data.ts new file mode 100644 index 00000000..929626c3 --- /dev/null +++ b/functions/src/scripts/denormalize-comment-bet-data.ts @@ -0,0 +1,69 @@ +// Filling in the bet-based fields on comments. + +import * as admin from 'firebase-admin' +import { zip } from 'lodash' +import { initAdmin } from './script-init' +import { + DocumentCorrespondence, + findDiffs, + describeDiff, + applyDiff, +} from './denormalize' +import { log } from '../utils' +import { Transaction } from 'firebase-admin/firestore' + +initAdmin() +const firestore = admin.firestore() + +async function getBetComments(transaction: Transaction) { + const allComments = await transaction.get( + firestore.collectionGroup('comments') + ) + const betComments = allComments.docs.filter((d) => d.get('betId')) + log(`Found ${betComments.length} comments associated with bets.`) + return betComments +} + +async function denormalize() { + let hasMore = true + while (hasMore) { + hasMore = await admin.firestore().runTransaction(async (trans) => { + const betComments = await getBetComments(trans) + const bets = await Promise.all( + betComments.map((doc) => + trans.get( + firestore + .collection('contracts') + .doc(doc.get('contractId')) + .collection('bets') + .doc(doc.get('betId')) + ) + ) + ) + log(`Found ${bets.length} bets associated with comments.`) + const mapping = zip(bets, betComments) + .map(([bet, comment]): DocumentCorrespondence => { + return [bet!, [comment!]] // eslint-disable-line + }) + .filter(([bet, _]) => bet.exists) // dev DB has some invalid bet IDs + + const amountDiffs = findDiffs(mapping, 'amount', 'betAmount') + const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome') + log(`Found ${amountDiffs.length} comments with mismatched amounts.`) + log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`) + const diffs = amountDiffs.concat(outcomeDiffs) + diffs.slice(0, 500).forEach((d) => { + log(describeDiff(d)) + applyDiff(trans, d) + }) + if (diffs.length > 500) { + console.log(`Applying first 500 because of Firestore limit...`) + } + return diffs.length > 500 + }) + } +} + +if (require.main === module) { + denormalize().catch((e) => console.error(e)) +} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 1aebb27b..fa2cc6f5 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -125,15 +125,12 @@ export function FeedComment(props: { } = props const { text, content, userUsername, userName, userAvatarUrl, createdTime } = comment - let betOutcome: string | undefined, - bought: string | undefined, - money: string | undefined - - const matchedBet = betsBySameUser.find((bet) => bet.id === comment.betId) - if (matchedBet) { - betOutcome = matchedBet.outcome - bought = matchedBet.amount >= 0 ? 'bought' : 'sold' - money = formatMoney(Math.abs(matchedBet.amount)) + const betOutcome = comment.betOutcome + let bought: string | undefined + let money: string | undefined + if (comment.betAmount != null) { + bought = comment.betAmount >= 0 ? 'bought' : 'sold' + money = formatMoney(Math.abs(comment.betAmount)) } const [highlighted, setHighlighted] = useState(false) @@ -148,7 +145,7 @@ export function FeedComment(props: { const { userPosition, outcome } = getBettorsLargestPositionBeforeTime( contract, comment.createdTime, - matchedBet ? [] : betsBySameUser + comment.betId ? [] : betsBySameUser ) return ( @@ -175,7 +172,7 @@ export function FeedComment(props: { username={userUsername} name={userName} />{' '} - {!matchedBet && + {!comment.betId != null && userPosition > 0 && contract.outcomeType !== 'NUMERIC' && ( <> @@ -194,7 +191,6 @@ export function FeedComment(props: { of{' '} <OutcomeLabel outcome={betOutcome ? betOutcome : ''} - value={(matchedBet as any).value} contract={contract} truncate="short" /> From 70eec6353367e1057cf69cc50e5469c5d26ce4b3 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 5 Sep 2022 10:07:33 -0700 Subject: [PATCH 54/82] Adding in "Highest %" and "Lowest %" sort options Quick alternative to https://github.com/manifoldmarkets/manifold/pull/850/files courtesy of James. One downside of this approach is that the % only update every 15 minutes; but maybe users won't notice? --- web/components/contract-search.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 8ace85eb..0beedc1b 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -48,6 +48,8 @@ export const SORTS = [ { label: 'Subsidy', value: 'liquidity' }, { label: 'Close date', value: 'close-date' }, { label: 'Resolve date', value: 'resolve-date' }, + { label: 'Highest %', value: 'prob-descending' }, + { label: 'Lowest %', value: 'prob-ascending' }, ] as const export type Sort = typeof SORTS[number]['value'] From 9a49c0b8fe99435023a16873aea3b6211e42018b Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 13:33:58 -0500 Subject: [PATCH 55/82] remove numeric, multiple choice markets from create market page --- web/pages/create.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index b5892ccf..7e1ead90 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -290,9 +290,9 @@ export function NewContract(props: { }} choicesMap={{ 'Yes / No': 'BINARY', - 'Multiple choice': 'MULTIPLE_CHOICE', + // 'Multiple choice': 'MULTIPLE_CHOICE', 'Free response': 'FREE_RESPONSE', - Numeric: 'PSEUDO_NUMERIC', + // Numeric: 'PSEUDO_NUMERIC', }} isSubmitting={isSubmitting} className={'col-span-4'} From d812776357203442628470dc54626309a92aa51e Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 5 Sep 2022 16:25:46 -0500 Subject: [PATCH 56/82] Remove show hot volume param --- web/components/contract/contract-card.tsx | 3 --- web/components/contract/contract-details.tsx | 22 +++++--------------- web/pages/experimental/home/index.tsx | 2 +- 3 files changed, 6 insertions(+), 21 deletions(-) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index e7c26fe0..dab92a7a 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -35,7 +35,6 @@ import { Tooltip } from '../tooltip' export function ContractCard(props: { contract: Contract - showHotVolume?: boolean showTime?: ShowTime className?: string questionClass?: string @@ -45,7 +44,6 @@ export function ContractCard(props: { trackingPostfix?: string }) { const { - showHotVolume, showTime, className, questionClass, @@ -147,7 +145,6 @@ export function ContractCard(props: { <AvatarDetails contract={contract} short={true} className="md:hidden" /> <MiscDetails contract={contract} - showHotVolume={showHotVolume} showTime={showTime} hideGroupLink={hideGroupLink} /> diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index e0eda8d6..48528029 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -2,7 +2,6 @@ import { ClockIcon, DatabaseIcon, PencilIcon, - TrendingUpIcon, UserGroupIcon, } from '@heroicons/react/outline' import clsx from 'clsx' @@ -40,30 +39,19 @@ export type ShowTime = 'resolve-date' | 'close-date' export function MiscDetails(props: { contract: Contract - showHotVolume?: boolean showTime?: ShowTime hideGroupLink?: boolean }) { - const { contract, showHotVolume, showTime, hideGroupLink } = props - const { - volume, - volume24Hours, - closeTime, - isResolved, - createdTime, - resolutionTime, - } = contract + const { contract, showTime, hideGroupLink } = props + const { volume, closeTime, isResolved, createdTime, resolutionTime } = + contract const isNew = createdTime > Date.now() - DAY_MS && !isResolved const groupToDisplay = getGroupLinkToDisplay(contract) return ( <Row className="items-center gap-3 truncate text-sm text-gray-400"> - {showHotVolume ? ( - <Row className="gap-0.5"> - <TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)} - </Row> - ) : showTime === 'close-date' ? ( + {showTime === 'close-date' ? ( <Row className="gap-0.5 whitespace-nowrap"> <ClockIcon className="h-5 w-5" /> {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '} @@ -369,7 +357,7 @@ function EditableCloseDate(props: { return ( <> {isEditingCloseTime ? ( - <Row className="z-10 mr-2 w-full shrink-0 items-start items-center gap-1"> + <Row className="z-10 mr-2 w-full shrink-0 items-center gap-1"> <input type="date" className="input input-bordered shrink-0" diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 7adc9ef1..2164e280 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -82,7 +82,7 @@ const Home = (props: { auth: { user: User } | null }) => { <SearchSection key={id} label={'Your bets'} - sort={'newest'} + sort={'prob-change-day'} user={user} yourBets /> From 97e0a7880643b3295fa5c7a7722d2ea733df525f Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 16:51:09 -0500 Subject: [PATCH 57/82] "join group" => "follow" --- web/components/groups/groups-button.tsx | 6 +++--- web/pages/group/[...slugs]/index.tsx | 24 ++++++++++++++---------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index f60ed0af..e6271466 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -107,7 +107,7 @@ export function JoinOrLeaveGroupButton(props: { onClick={firebaseLogin} className={clsx('btn btn-sm', small && smallStyle, className)} > - Login to Join + Login to follow </button> ) } @@ -132,7 +132,7 @@ export function JoinOrLeaveGroupButton(props: { )} onClick={withTracking(onLeaveGroup, 'leave group')} > - Leave + Unfollow </button> ) } @@ -144,7 +144,7 @@ export function JoinOrLeaveGroupButton(props: { className={clsx('btn btn-sm', small && smallStyle, className)} onClick={withTracking(onJoinGroup, 'join group')} > - Join + Follow </button> ) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index b4046c4c..4df21faf 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -52,6 +52,7 @@ import { Post } from 'common/post' import { Spacer } from 'web/components/layout/spacer' import { usePost } from 'web/hooks/use-post' import { useAdmin } from 'web/hooks/use-admin' +import { track } from '@amplitude/analytics-browser' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -659,22 +660,25 @@ function JoinGroupButton(props: { user: User | null | undefined }) { const { group, user } = props - function addUserToGroup() { - if (user) { - toast.promise(joinGroup(group, user.id), { - loading: 'Joining group...', - success: 'Joined group!', - error: "Couldn't join group, try again?", - }) - } + + const follow = async () => { + track('join group') + const userId = user ? user.id : (await firebaseLogin()).user.uid + + toast.promise(joinGroup(group, userId), { + loading: 'Following group...', + success: 'Followed', + error: "Couldn't follow group, try again?", + }) } + return ( <div> <button - onClick={user ? addUserToGroup : firebaseLogin} + onClick={follow} className={'btn-md btn-outline btn whitespace-nowrap normal-case'} > - {user ? 'Join group' : 'Login to join group'} + Follow </button> </div> ) From 30d73d6362818694c9cbff9ab5deb82823f84c68 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 16:59:35 -0500 Subject: [PATCH 58/82] remove parantheses from balance text --- web/components/answers/answer-bet-panel.tsx | 2 +- web/components/answers/create-answer-panel.tsx | 2 +- web/components/bet-panel.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index c5897056..8a29148e 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -134,7 +134,7 @@ export function AnswerBetPanel(props: { </Row> <Row className="my-3 justify-between text-left text-sm text-gray-500"> Amount - <span>(balance: {formatMoney(user?.balance ?? 0)})</span> + <span>Balance: {formatMoney(user?.balance ?? 0)}</span> </Row> <BuyAmountInput inputClassName="w-full max-w-none" diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 38aeac0e..cd962454 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -152,7 +152,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { <Row className="my-3 justify-between text-left text-sm text-gray-500"> Bet Amount <span className={'sm:hidden'}> - (balance: {formatMoney(user?.balance ?? 0)}) + Balance: {formatMoney(user?.balance ?? 0)} </span> </Row>{' '} <BuyAmountInput diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 311a6182..ab3d8958 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -310,7 +310,7 @@ function BuyPanel(props: { <Row className="my-3 justify-between text-left text-sm text-gray-500"> Amount <span className={'xl:hidden'}> - (balance: {formatMoney(user?.balance ?? 0)}) + Balance: {formatMoney(user?.balance ?? 0)} </span> </Row> <BuyAmountInput @@ -606,7 +606,7 @@ function LimitOrderPanel(props: { Max amount<span className="ml-1 text-red-500">*</span> </span> <span className={'xl:hidden'}> - (balance: {formatMoney(user?.balance ?? 0)}) + Balance: {formatMoney(user?.balance ?? 0)} </span> </Row> <BuyAmountInput From ae40999700bd376d5a08197e3f72f20b8aa3f38d Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 17:11:32 -0500 Subject: [PATCH 59/82] mobile bet slider --- web/components/amount-input.tsx | 35 +++++++++++++++------ web/components/answers/answer-bet-panel.tsx | 2 ++ web/components/bet-panel.tsx | 4 +++ 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 971a5496..f1eedc88 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -84,6 +84,7 @@ export function BuyAmountInput(props: { setError: (error: string | undefined) => void minimumAmount?: number disabled?: boolean + showSliderOnMobile?: boolean className?: string inputClassName?: string // Needed to focus the amount input @@ -94,6 +95,7 @@ export function BuyAmountInput(props: { onChange, error, setError, + showSliderOnMobile: showSlider, disabled, className, inputClassName, @@ -121,15 +123,28 @@ export function BuyAmountInput(props: { } return ( - <AmountInput - amount={amount} - onChange={onAmountChange} - label={ENV_CONFIG.moneyMoniker} - error={error} - disabled={disabled} - className={className} - inputClassName={inputClassName} - inputRef={inputRef} - /> + <> + <AmountInput + amount={amount} + onChange={onAmountChange} + label={ENV_CONFIG.moneyMoniker} + error={error} + disabled={disabled} + className={className} + inputClassName={inputClassName} + inputRef={inputRef} + /> + {showSlider && ( + <input + type="range" + min="0" + max="250" + value={amount ?? 0} + onChange={(e) => onAmountChange(parseInt(e.target.value))} + className="xl:hidden" + step="25" + /> + )} + </> ) } diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 8a29148e..ace06b6c 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -136,6 +136,7 @@ export function AnswerBetPanel(props: { Amount <span>Balance: {formatMoney(user?.balance ?? 0)}</span> </Row> + <BuyAmountInput inputClassName="w-full max-w-none" amount={betAmount} @@ -144,6 +145,7 @@ export function AnswerBetPanel(props: { setError={setError} disabled={isSubmitting} inputRef={inputRef} + showSliderOnMobile /> {(betAmount ?? 0) > 10 && diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index ab3d8958..c48e92a9 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -313,6 +313,7 @@ function BuyPanel(props: { Balance: {formatMoney(user?.balance ?? 0)} </span> </Row> + <BuyAmountInput inputClassName="w-full max-w-none" amount={betAmount} @@ -321,6 +322,7 @@ function BuyPanel(props: { setError={setError} disabled={isSubmitting} inputRef={inputRef} + showSliderOnMobile /> {warning} @@ -609,6 +611,7 @@ function LimitOrderPanel(props: { Balance: {formatMoney(user?.balance ?? 0)} </span> </Row> + <BuyAmountInput inputClassName="w-full max-w-none" amount={betAmount} @@ -616,6 +619,7 @@ function LimitOrderPanel(props: { error={error} setError={setError} disabled={isSubmitting} + showSliderOnMobile /> <Col className="mt-3 w-full gap-3"> From 96cf1a5f7fc3824ceb8c0e2d7fc102687386b4c1 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 17:39:59 -0500 Subject: [PATCH 60/82] mobile slider styling --- web/components/amount-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index f1eedc88..eb834b51 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -141,7 +141,7 @@ export function BuyAmountInput(props: { max="250" value={amount ?? 0} onChange={(e) => onAmountChange(parseInt(e.target.value))} - className="xl:hidden" + className="range range-lg range-primary mb-2 z-40 xl:hidden " step="25" /> )} From 374c25ffb34273d5d8f61bad8032e6bc5b4e5ca4 Mon Sep 17 00:00:00 2001 From: mantikoros <mantikoros@users.noreply.github.com> Date: Mon, 5 Sep 2022 22:40:48 +0000 Subject: [PATCH 61/82] Auto-prettification --- web/components/amount-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index eb834b51..bd94a5d1 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -141,7 +141,7 @@ export function BuyAmountInput(props: { max="250" value={amount ?? 0} onChange={(e) => onAmountChange(parseInt(e.target.value))} - className="range range-lg range-primary mb-2 z-40 xl:hidden " + className="range range-lg range-primary z-40 mb-2 xl:hidden " step="25" /> )} From 2d724bf2c8a43472ec131c1a846901766fa9f9b3 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 17:43:46 -0500 Subject: [PATCH 62/82] make slider black --- web/components/amount-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index bd94a5d1..459cfe5a 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -141,7 +141,7 @@ export function BuyAmountInput(props: { max="250" value={amount ?? 0} onChange={(e) => onAmountChange(parseInt(e.target.value))} - className="range range-lg range-primary z-40 mb-2 xl:hidden " + className="range range-lg z-40 mb-2 xl:hidden " step="25" /> )} From 8952b100adbff6713c125eb7a756a46ee093e6b8 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 17:59:19 -0500 Subject: [PATCH 63/82] add answer panel mobile formatting, slider --- web/components/amount-input.tsx | 2 +- web/components/answers/create-answer-panel.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 459cfe5a..08a9720a 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -141,7 +141,7 @@ export function BuyAmountInput(props: { max="250" value={amount ?? 0} onChange={(e) => onAmountChange(parseInt(e.target.value))} - className="range range-lg z-40 mb-2 xl:hidden " + className="range range-lg z-40 mb-2 xl:hidden" step="25" /> )} diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index cd962454..7e20e92e 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -120,7 +120,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { return ( <Col className="gap-4 rounded"> - <Col className="flex-1 gap-2"> + <Col className="flex-1 gap-2 px-4 xl:px-0"> <div className="mb-1">Add your answer</div> <Textarea value={text} @@ -162,6 +162,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { setError={setAmountError} minimumAmount={1} disabled={isSubmitting} + showSliderOnMobile /> </Col> <Col className="gap-3"> @@ -205,7 +206,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { disabled={!canSubmit} onClick={withTracking(submitAnswer, 'submit answer')} > - Submit answer & buy + Submit </button> ) : ( text && ( From 837a4d8949a77b000e415a566d92755609273f85 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 18:07:44 -0500 Subject: [PATCH 64/82] Revert "Show challenge on desktop, simplify modal" This reverts commit 8922b370cc2e562e796ae3c58a2eb5e7f7609af1. --- .../challenges/create-challenge-modal.tsx | 111 +++++++++++------- web/components/contract/share-modal.tsx | 40 +------ 2 files changed, 74 insertions(+), 77 deletions(-) diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx index 72a8fd7b..6f91a6d4 100644 --- a/web/components/challenges/create-challenge-modal.tsx +++ b/web/components/challenges/create-challenge-modal.tsx @@ -18,6 +18,7 @@ import { NoLabel, YesLabel } from '../outcome-label' import { QRCode } from '../qr-code' import { copyToClipboard } from 'web/lib/util/copy' import { AmountInput } from '../amount-input' +import { getProbability } from 'common/calculate' import { createMarket } from 'web/lib/firebase/api' import { removeUndefinedProps } from 'common/util/object' import { FIXED_ANTE } from 'common/economy' @@ -25,7 +26,6 @@ import Textarea from 'react-expanding-textarea' import { useTextEditor } from 'web/components/editor' import { LoadingIndicator } from 'web/components/loading-indicator' import { track } from 'web/lib/service/analytics' -import { useWindowSize } from 'web/hooks/use-window-size' type challengeInfo = { amount: number @@ -110,9 +110,8 @@ function CreateChallengeForm(props: { const [isCreating, setIsCreating] = useState(false) const [finishedCreating, setFinishedCreating] = useState(false) const [error, setError] = useState<string>('') + const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false) const defaultExpire = 'week' - const { width } = useWindowSize() - const isMobile = (width ?? 0) < 768 const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({ expiresTime: dayjs().add(2, defaultExpire).valueOf(), @@ -148,7 +147,7 @@ function CreateChallengeForm(props: { setFinishedCreating(true) }} > - <Title className="!mt-2 hidden sm:block" text="Challenge bet " /> + <Title className="!mt-2" text="Challenge bet " /> <div className="mb-8"> Challenge a friend to bet on{' '} @@ -158,7 +157,7 @@ function CreateChallengeForm(props: { <Textarea placeholder="e.g. Will a Democrat be the next president?" className="input input-bordered mt-1 w-full resize-none" - autoFocus={!isMobile} + autoFocus={true} maxLength={MAX_QUESTION_LENGTH} value={challengeInfo.question} onChange={(e) => @@ -171,59 +170,89 @@ function CreateChallengeForm(props: { )} </div> - <Col className="mt-2 flex-wrap justify-center gap-x-5 gap-y-0 sm:gap-y-2"> - <Col> - <div>You'll bet:</div> - <Row - className={ - 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' + <div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2"> + <div>You'll bet:</div> + <Row + className={ + 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' + } + > + <AmountInput + amount={challengeInfo.amount || undefined} + onChange={(newAmount) => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + amount: newAmount ?? 0, + acceptorAmount: editingAcceptorAmount + ? m.acceptorAmount + : newAmount ?? 0, + } + }) + } + error={undefined} + label={'M$'} + inputClassName="w-24" + /> + <span className={''}>on</span> + {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} + </Row> + <Row className={'mt-3 max-w-xs justify-end'}> + <Button + color={'gray-white'} + onClick={() => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + outcome: m.outcome === 'YES' ? 'NO' : 'YES', + } + }) } > + <SwitchVerticalIcon className={'h-6 w-6'} /> + </Button> + </Row> + <Row className={'items-center'}>If they bet:</Row> + <Row className={'max-w-xs items-center justify-between gap-4 pr-3'}> + <div className={'w-32 sm:mr-1'}> <AmountInput - amount={challengeInfo.amount || undefined} - onChange={(newAmount) => + amount={challengeInfo.acceptorAmount || undefined} + onChange={(newAmount) => { + setEditingAcceptorAmount(true) + setChallengeInfo((m: challengeInfo) => { return { ...m, - amount: newAmount ?? 0, acceptorAmount: newAmount ?? 0, } }) - } + }} error={undefined} label={'M$'} inputClassName="w-24" /> - <span className={''}>on</span> - {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} - </Row> - <Row className={'max-w-xs justify-end'}> - <Button - color={'gray-white'} - onClick={() => - setChallengeInfo((m: challengeInfo) => { - return { - ...m, - outcome: m.outcome === 'YES' ? 'NO' : 'YES', - } - }) - } - > - <SwitchVerticalIcon className={'h-6 w-6'} /> - </Button> - </Row> - </Col> - <Row className={'items-center'}>If they bet:</Row> - <Row className={'max-w-xs items-center justify-between gap-4 pr-3'}> - <div className={'mt-1 w-32 sm:mr-1'}> - <span className={'ml-2 font-bold'}> - {formatMoney(challengeInfo.acceptorAmount)} - </span> </div> <span>on</span> {challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} </Row> - </Col> + </div> + {contract && ( + <Button + size="2xs" + color="gray" + onClick={() => { + setEditingAcceptorAmount(true) + + const p = getProbability(contract) + const prob = challengeInfo.outcome === 'YES' ? p : 1 - p + const { amount } = challengeInfo + const acceptorAmount = Math.round(amount / prob - amount) + setChallengeInfo({ ...challengeInfo, acceptorAmount }) + }} + > + Use market odds + </Button> + )} <div className="mt-8"> If the challenge is accepted, whoever is right will earn{' '} <span className="font-semibold"> diff --git a/web/components/contract/share-modal.tsx b/web/components/contract/share-modal.tsx index ff3f41ae..2cf8b484 100644 --- a/web/components/contract/share-modal.tsx +++ b/web/components/contract/share-modal.tsx @@ -12,15 +12,13 @@ import { TweetButton } from '../tweet-button' import { DuplicateContractButton } from '../copy-contract-button' import { Button } from '../button' import { copyToClipboard } from 'web/lib/util/copy' -import { track, withTracking } from 'web/lib/service/analytics' +import { track } from 'web/lib/service/analytics' import { ENV_CONFIG } from 'common/envs/constants' import { User } from 'common/user' import { SiteLink } from '../site-link' import { formatMoney } from 'common/util/format' import { REFERRAL_AMOUNT } from 'common/economy' -import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' import { useState } from 'react' -import { CHALLENGES_ENABLED } from 'common/challenge' export function ShareModal(props: { contract: Contract @@ -29,14 +27,9 @@ export function ShareModal(props: { setOpen: (open: boolean) => void }) { const { contract, user, isOpen, setOpen } = props - const { outcomeType, resolution } = contract - const [openCreateChallengeModal, setOpenCreateChallengeModal] = - useState(false) + useState(false) const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> - const showChallenge = - user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED - const shareUrl = `https://${ENV_CONFIG.domain}${contractPath(contract)}${ user?.username && contract.creatorUsername !== user?.username ? '?referrer=' + user?.username @@ -45,7 +38,7 @@ export function ShareModal(props: { return ( <Modal open={isOpen} setOpen={setOpen} size="md"> - <Col className="gap-2.5 rounded bg-white p-4 sm:gap-4"> + <Col className="gap-4 rounded bg-white p-4"> <Title className="!mt-0 !mb-2" text="Share this market" /> <p> Earn{' '} @@ -57,7 +50,7 @@ export function ShareModal(props: { <Button size="2xl" color="gradient" - className={'flex max-w-xs self-center'} + className={'mb-2 flex max-w-xs self-center'} onClick={() => { copyToClipboard(shareUrl) toast.success('Link copied!', { @@ -68,31 +61,6 @@ export function ShareModal(props: { > {linkIcon} Copy link </Button> - <Row className={'justify-center'}>or</Row> - {showChallenge && ( - <Button - size="2xl" - color="gradient" - className={'mb-2 flex max-w-xs self-center'} - onClick={withTracking( - () => setOpenCreateChallengeModal(true), - 'click challenge button' - )} - > - <span>⚔️ Challenge</span> - <CreateChallengeModal - isOpen={openCreateChallengeModal} - setOpen={(open) => { - if (!open) { - setOpenCreateChallengeModal(false) - setOpen(false) - } else setOpenCreateChallengeModal(open) - }} - user={user} - contract={contract} - /> - </Button> - )} <Row className="z-0 flex-wrap justify-center gap-4 self-center"> <TweetButton className="self-start" From cd8bb72f9443c957bc4be0b5b9dc3db2fddc9c75 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 5 Sep 2022 18:09:01 -0500 Subject: [PATCH 65/82] Daily movers table in experimental/home --- web/components/contract/prob-change-table.tsx | 72 +++++++++++++++++++ web/hooks/use-prob-changes.tsx | 22 ++++++ web/lib/firebase/contracts.ts | 20 +++++- web/pages/experimental/home/index.tsx | 62 ++++++++-------- 4 files changed, 147 insertions(+), 29 deletions(-) create mode 100644 web/components/contract/prob-change-table.tsx create mode 100644 web/hooks/use-prob-changes.tsx diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx new file mode 100644 index 00000000..9f1f171d --- /dev/null +++ b/web/components/contract/prob-change-table.tsx @@ -0,0 +1,72 @@ +import clsx from 'clsx' +import { contractPath } from 'web/lib/firebase/contracts' +import { CPMMContract } from 'common/contract' +import { formatPercent } from 'common/util/format' +import { useProbChanges } from 'web/hooks/use-prob-changes' +import { SiteLink } from '../site-link' + +export function ProbChangeTable(props: { userId: string | undefined }) { + const { userId } = props + + const changes = useProbChanges(userId ?? '') + console.log('changes', changes) + + if (!changes) { + return null + } + + const { positiveChanges, negativeChanges } = changes + + const count = 3 + + return ( + <div className="grid max-w-xl gap-x-2 gap-y-2 rounded bg-white p-4 text-gray-700"> + <div className="text-xl text-gray-800">Daily movers</div> + <div className="text-right">% pts</div> + {positiveChanges.slice(0, count).map((contract) => ( + <> + <div className="line-clamp-2"> + <SiteLink href={contractPath(contract)}> + {contract.question} + </SiteLink> + </div> + <ProbChange className="text-right" contract={contract} /> + </> + ))} + <div className="col-span-2 my-2" /> + {negativeChanges.slice(0, count).map((contract) => ( + <> + <div className="line-clamp-2"> + <SiteLink href={contractPath(contract)}> + {contract.question} + </SiteLink> + </div> + <ProbChange className="text-right" contract={contract} /> + </> + ))} + </div> + ) +} + +export function ProbChange(props: { + contract: CPMMContract + className?: string +}) { + const { contract, className } = props + const { + probChanges: { day: change }, + } = contract + + const color = + change > 0 + ? 'text-green-500' + : change < 0 + ? 'text-red-500' + : 'text-gray-500' + + const str = + change === 0 + ? '+0%' + : `${change > 0 ? '+' : '-'}${formatPercent(Math.abs(change))}` + return <div className={clsx(className, color)}>{str}</div> +} diff --git a/web/hooks/use-prob-changes.tsx b/web/hooks/use-prob-changes.tsx new file mode 100644 index 00000000..c5e2c9bd --- /dev/null +++ b/web/hooks/use-prob-changes.tsx @@ -0,0 +1,22 @@ +import { useFirestoreQueryData } from '@react-query-firebase/firestore' +import { + getProbChangesNegative, + getProbChangesPositive, +} from 'web/lib/firebase/contracts' + +export const useProbChanges = (userId: string) => { + const { data: positiveChanges } = useFirestoreQueryData( + ['prob-changes-day-positive', userId], + getProbChangesPositive(userId) + ) + const { data: negativeChanges } = useFirestoreQueryData( + ['prob-changes-day-negative', userId], + getProbChangesNegative(userId) + ) + + if (!positiveChanges || !negativeChanges) { + return undefined + } + + return { positiveChanges, negativeChanges } +} diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 5c65b23f..702f1c99 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -16,7 +16,7 @@ import { import { partition, sortBy, sum, uniqBy } from 'lodash' import { coll, getValues, listenForValue, listenForValues } from './utils' -import { BinaryContract, Contract } from 'common/contract' +import { BinaryContract, Contract, CPMMContract } from 'common/contract' import { createRNG, shuffle } from 'common/util/random' import { formatMoney, formatPercent } from 'common/util/format' import { DAY_MS } from 'common/util/time' @@ -402,3 +402,21 @@ export async function getRecentBetsAndComments(contract: Contract) { recentComments, } } + +export const getProbChangesPositive = (userId: string) => + query( + contracts, + where('uniqueBettorIds', 'array-contains', userId), + where('probChanges.day', '>', 0), + orderBy('probChanges.day', 'desc'), + limit(10) + ) as Query<CPMMContract> + +export const getProbChangesNegative = (userId: string) => + query( + contracts, + where('uniqueBettorIds', 'array-contains', userId), + where('probChanges.day', '<', 0), + orderBy('probChanges.day', 'asc'), + limit(10) + ) as Query<CPMMContract> diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 2164e280..9e393d4f 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -25,6 +25,7 @@ import { Button } from 'web/components/button' import { ArrangeHome, getHomeItems } from '../../../components/arrange-home' import { Title } from 'web/components/title' import { Row } from 'web/components/layout/row' +import { ProbChangeTable } from 'web/components/contract/prob-change-table' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) @@ -75,36 +76,40 @@ const Home = (props: { auth: { user: User } | null }) => { /> </> ) : ( - visibleItems.map((item) => { - const { id } = item - if (id === 'your-bets') { - return ( - <SearchSection - key={id} - label={'Your bets'} - sort={'prob-change-day'} - user={user} - yourBets - /> - ) - } - const sort = SORTS.find((sort) => sort.value === id) - if (sort) - return ( - <SearchSection - key={id} - label={sort.label} - sort={sort.value} - user={user} - /> - ) + <> + <ProbChangeTable userId={user?.id} /> - const group = groups.find((g) => g.id === id) - if (group) - return <GroupSection key={id} group={group} user={user} /> + {visibleItems.map((item) => { + const { id } = item + if (id === 'your-bets') { + return ( + <SearchSection + key={id} + label={'Your bets'} + sort={'prob-change-day'} + user={user} + yourBets + /> + ) + } + const sort = SORTS.find((sort) => sort.value === id) + if (sort) + return ( + <SearchSection + key={id} + label={sort.label} + sort={sort.value} + user={user} + /> + ) - return null - }) + const group = groups.find((g) => g.id === id) + if (group) + return <GroupSection key={id} group={group} user={user} /> + + return null + })} + </> )} </Col> <button @@ -151,6 +156,7 @@ function SearchSection(props: { ? sort : undefined } + showProbChange={sort === 'prob-change-day'} loadMore={loadMore} /> ) : ( From f21711f3dc4b52b8228da38c3dd677bf09428133 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 5 Sep 2022 18:13:01 -0500 Subject: [PATCH 66/82] Fix type error --- web/pages/experimental/home/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 9e393d4f..606b66c4 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -156,7 +156,6 @@ function SearchSection(props: { ? sort : undefined } - showProbChange={sort === 'prob-change-day'} loadMore={loadMore} /> ) : ( From 450b140f5f10a4005480c716a037fffd04b4f33e Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 18:19:06 -0500 Subject: [PATCH 67/82] show challenge button on mobile --- .../contract/extra-contract-actions-row.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index f84655ec..d4918783 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -42,7 +42,6 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { /> <span>Share</span> </Col> - <ShareModal isOpen={isShareOpen} setOpen={setShareOpen} @@ -50,17 +49,23 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { user={user} /> </Button> + {showChallenge && ( <Button size="lg" color="gray-white" - className={'flex hidden max-w-xs self-center sm:inline-block'} + className="max-w-xs self-center" onClick={withTracking( () => setOpenCreateChallengeModal(true), 'click challenge button' )} > - <span>⚔️ Challenge</span> + <Col className="items-center sm:flex-row"> + <span className="h-[24px] w-5 sm:mr-2" aria-hidden="true"> + ⚔️ + </span> + <span>Challenge</span> + </Col> <CreateChallengeModal isOpen={openCreateChallengeModal} setOpen={setOpenCreateChallengeModal} From 59f3936dad81ed4686685dfb187aadd5b5a29ec5 Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Tue, 6 Sep 2022 14:17:21 +0100 Subject: [PATCH 68/82] Fix bug (#854) --- web/lib/firebase/contracts.ts | 5 +++-- web/pages/tournaments/index.tsx | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 702f1c99..51ec3108 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -104,7 +104,7 @@ export async function listContracts(creatorId: string): Promise<Contract[]> { return snapshot.docs.map((doc) => doc.data()) } -export const contractsByGroupSlugQuery = (slug: string) => +export const tournamentContractsByGroupSlugQuery = (slug: string) => query( contracts, where('groupSlugs', 'array-contains', slug), @@ -115,7 +115,8 @@ export const contractsByGroupSlugQuery = (slug: string) => export async function listContractsByGroupSlug( slug: string ): Promise<Contract[]> { - const snapshot = await getDocs(contractsByGroupSlugQuery(slug)) + const q = query(contracts, where('groupSlugs', 'array-contains', slug)) + const snapshot = await getDocs(q) return snapshot.docs.map((doc) => doc.data()) } diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index 1a74e8ea..4b573e3f 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -14,7 +14,7 @@ import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { SEO } from 'web/components/SEO' -import { contractsByGroupSlugQuery } from 'web/lib/firebase/contracts' +import { tournamentContractsByGroupSlugQuery } from 'web/lib/firebase/contracts' import { getGroup, groupPath } from 'web/lib/firebase/groups' import elon_pic from './_cspi/Will_Elon_Buy_Twitter.png' import china_pic from './_cspi/Chinese_Military_Action_against_Taiwan.png' @@ -222,7 +222,7 @@ const ImageCarousel = (props: { images: MarketImage[]; url: string }) => { const MarketCarousel = (props: { slug: string }) => { const { slug } = props - const q = contractsByGroupSlugQuery(slug) + const q = tournamentContractsByGroupSlugQuery(slug) const { allItems, getNext } = usePagination({ q, pageSize: 6 }) const items = allItems() From a3b18e5beac9f3b6c6612a773e0ce1f13df41daf Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 07:57:52 -0600 Subject: [PATCH 69/82] Add challenge back to share modal --- web/components/contract/share-modal.tsx | 40 ++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/web/components/contract/share-modal.tsx b/web/components/contract/share-modal.tsx index 2cf8b484..ff3f41ae 100644 --- a/web/components/contract/share-modal.tsx +++ b/web/components/contract/share-modal.tsx @@ -12,13 +12,15 @@ import { TweetButton } from '../tweet-button' import { DuplicateContractButton } from '../copy-contract-button' import { Button } from '../button' import { copyToClipboard } from 'web/lib/util/copy' -import { track } from 'web/lib/service/analytics' +import { track, withTracking } from 'web/lib/service/analytics' import { ENV_CONFIG } from 'common/envs/constants' import { User } from 'common/user' import { SiteLink } from '../site-link' import { formatMoney } from 'common/util/format' import { REFERRAL_AMOUNT } from 'common/economy' +import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' import { useState } from 'react' +import { CHALLENGES_ENABLED } from 'common/challenge' export function ShareModal(props: { contract: Contract @@ -27,9 +29,14 @@ export function ShareModal(props: { setOpen: (open: boolean) => void }) { const { contract, user, isOpen, setOpen } = props + const { outcomeType, resolution } = contract - useState(false) + const [openCreateChallengeModal, setOpenCreateChallengeModal] = + useState(false) const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> + const showChallenge = + user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED + const shareUrl = `https://${ENV_CONFIG.domain}${contractPath(contract)}${ user?.username && contract.creatorUsername !== user?.username ? '?referrer=' + user?.username @@ -38,7 +45,7 @@ export function ShareModal(props: { return ( <Modal open={isOpen} setOpen={setOpen} size="md"> - <Col className="gap-4 rounded bg-white p-4"> + <Col className="gap-2.5 rounded bg-white p-4 sm:gap-4"> <Title className="!mt-0 !mb-2" text="Share this market" /> <p> Earn{' '} @@ -50,7 +57,7 @@ export function ShareModal(props: { <Button size="2xl" color="gradient" - className={'mb-2 flex max-w-xs self-center'} + className={'flex max-w-xs self-center'} onClick={() => { copyToClipboard(shareUrl) toast.success('Link copied!', { @@ -61,6 +68,31 @@ export function ShareModal(props: { > {linkIcon} Copy link </Button> + <Row className={'justify-center'}>or</Row> + {showChallenge && ( + <Button + size="2xl" + color="gradient" + className={'mb-2 flex max-w-xs self-center'} + onClick={withTracking( + () => setOpenCreateChallengeModal(true), + 'click challenge button' + )} + > + <span>⚔️ Challenge</span> + <CreateChallengeModal + isOpen={openCreateChallengeModal} + setOpen={(open) => { + if (!open) { + setOpenCreateChallengeModal(false) + setOpen(false) + } else setOpenCreateChallengeModal(open) + }} + user={user} + contract={contract} + /> + </Button> + )} <Row className="z-0 flex-wrap justify-center gap-4 self-center"> <TweetButton className="self-start" From 39d7f1055bfb682a8f4e81c977eefebe6360c4cd Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 07:58:00 -0600 Subject: [PATCH 70/82] Fix spacing on challenge modal --- .../challenges/create-challenge-modal.tsx | 114 +++++++++--------- 1 file changed, 59 insertions(+), 55 deletions(-) diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx index 6f91a6d4..6c810a44 100644 --- a/web/components/challenges/create-challenge-modal.tsx +++ b/web/components/challenges/create-challenge-modal.tsx @@ -147,7 +147,7 @@ function CreateChallengeForm(props: { setFinishedCreating(true) }} > - <Title className="!mt-2" text="Challenge bet " /> + <Title className="!mt-2 hidden sm:block" text="Challenge bet " /> <div className="mb-8"> Challenge a friend to bet on{' '} @@ -170,72 +170,76 @@ function CreateChallengeForm(props: { )} </div> - <div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2"> - <div>You'll bet:</div> - <Row - className={ - 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' - } - > - <AmountInput - amount={challengeInfo.amount || undefined} - onChange={(newAmount) => - setChallengeInfo((m: challengeInfo) => { - return { - ...m, - amount: newAmount ?? 0, - acceptorAmount: editingAcceptorAmount - ? m.acceptorAmount - : newAmount ?? 0, - } - }) - } - error={undefined} - label={'M$'} - inputClassName="w-24" - /> - <span className={''}>on</span> - {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} - </Row> - <Row className={'mt-3 max-w-xs justify-end'}> - <Button - color={'gray-white'} - onClick={() => - setChallengeInfo((m: challengeInfo) => { - return { - ...m, - outcome: m.outcome === 'YES' ? 'NO' : 'YES', - } - }) + <Col className="mt-2 flex-wrap justify-center gap-x-5 sm:gap-y-2"> + <Col> + <div>You'll bet:</div> + <Row + className={ + 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' } > - <SwitchVerticalIcon className={'h-6 w-6'} /> - </Button> - </Row> - <Row className={'items-center'}>If they bet:</Row> - <Row className={'max-w-xs items-center justify-between gap-4 pr-3'}> - <div className={'w-32 sm:mr-1'}> <AmountInput - amount={challengeInfo.acceptorAmount || undefined} - onChange={(newAmount) => { - setEditingAcceptorAmount(true) - + amount={challengeInfo.amount || undefined} + onChange={(newAmount) => setChallengeInfo((m: challengeInfo) => { return { ...m, - acceptorAmount: newAmount ?? 0, + amount: newAmount ?? 0, + acceptorAmount: editingAcceptorAmount + ? m.acceptorAmount + : newAmount ?? 0, } }) - }} + } error={undefined} label={'M$'} inputClassName="w-24" /> - </div> - <span>on</span> - {challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} - </Row> - </div> + <span className={''}>on</span> + {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} + </Row> + <Row className={'mt-3 max-w-xs justify-end'}> + <Button + color={'gray-white'} + onClick={() => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + outcome: m.outcome === 'YES' ? 'NO' : 'YES', + } + }) + } + > + <SwitchVerticalIcon className={'h-6 w-6'} /> + </Button> + </Row> + <Row className={'items-center'}>If they bet:</Row> + <Row + className={'max-w-xs items-center justify-between gap-4 pr-3'} + > + <div className={'w-32 sm:mr-1'}> + <AmountInput + amount={challengeInfo.acceptorAmount || undefined} + onChange={(newAmount) => { + setEditingAcceptorAmount(true) + + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + acceptorAmount: newAmount ?? 0, + } + }) + }} + error={undefined} + label={'M$'} + inputClassName="w-24" + /> + </div> + <span>on</span> + {challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} + </Row> + </Col> + </Col> {contract && ( <Button size="2xs" From 2ee067c072f629e5d5eede8f2ca3d654e5a33095 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 08:14:13 -0600 Subject: [PATCH 71/82] Remove member and contract ids from group doc --- common/group.ts | 4 ---- functions/src/scripts/update-groups.ts | 17 +++++++++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/common/group.ts b/common/group.ts index 5c716dba..19f3b7b8 100644 --- a/common/group.ts +++ b/common/group.ts @@ -12,10 +12,6 @@ export type Group = { aboutPostId?: string chatDisabled?: boolean mostRecentContractAddedTime?: number - /** @deprecated - members and contracts now stored as subcollections*/ - memberIds?: string[] // Deprecated - /** @deprecated - members and contracts now stored as subcollections*/ - contractIds?: string[] // Deprecated } export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 diff --git a/functions/src/scripts/update-groups.ts b/functions/src/scripts/update-groups.ts index 952a0d55..05666ab5 100644 --- a/functions/src/scripts/update-groups.ts +++ b/functions/src/scripts/update-groups.ts @@ -86,7 +86,7 @@ async function convertGroupFieldsToGroupDocuments() { } } } - +// eslint-disable-next-line @typescript-eslint/no-unused-vars async function updateTotalContractsAndMembers() { const groups = await getGroups() for (const group of groups) { @@ -101,9 +101,22 @@ async function updateTotalContractsAndMembers() { }) } } +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function removeUnusedMemberAndContractFields() { + const groups = await getGroups() + for (const group of groups) { + log('removing member and contract ids', group.slug) + const groupRef = admin.firestore().collection('groups').doc(group.id) + await groupRef.update({ + memberIds: admin.firestore.FieldValue.delete(), + contractIds: admin.firestore.FieldValue.delete(), + }) + } +} if (require.main === module) { initAdmin() // convertGroupFieldsToGroupDocuments() - updateTotalContractsAndMembers() + // updateTotalContractsAndMembers() + removeUnusedMemberAndContractFields() } From 5af92a7d8184564ac13ed998a0791c01a0c8eeac Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 09:24:26 -0600 Subject: [PATCH 72/82] Update groups API --- docs/docs/api.md | 12 ++++- web/lib/firebase/groups.ts | 51 +++++++++++++------ .../v0/group/by-id/{[id].ts => [id]/index.ts} | 0 web/pages/api/v0/group/by-id/[id]/markets.ts | 18 +++++++ web/pages/api/v0/groups.ts | 34 +++++++++++-- 5 files changed, 95 insertions(+), 20 deletions(-) rename web/pages/api/v0/group/by-id/{[id].ts => [id]/index.ts} (100%) create mode 100644 web/pages/api/v0/group/by-id/[id]/markets.ts diff --git a/docs/docs/api.md b/docs/docs/api.md index c02a5141..e284abdf 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -54,6 +54,10 @@ Returns the authenticated user. Gets all groups, in no particular order. +Parameters: +- `availableToUserId`: Optional. if specified, only groups that the user can + join and groups they've already joined will be returned. + Requires no authorization. ### `GET /v0/groups/[slug]` @@ -62,12 +66,18 @@ Gets a group by its slug. Requires no authorization. -### `GET /v0/groups/by-id/[id]` +### `GET /v0/group/by-id/[id]` Gets a group by its unique ID. Requires no authorization. +### `GET /v0/group/by-id/[id]/markets` + +Gets a group's markets by its unique ID. + +Requires no authorization. + ### `GET /v0/markets` Lists all markets, ordered by creation date descending. diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index ef67ff14..36bfe7cc 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -11,7 +11,7 @@ import { updateDoc, where, } from 'firebase/firestore' -import { uniq } from 'lodash' +import { uniq, uniqBy } from 'lodash' import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group' import { coll, @@ -21,7 +21,7 @@ import { listenForValues, } from './utils' import { Contract } from 'common/contract' -import { updateContract } from 'web/lib/firebase/contracts' +import { getContractFromId, updateContract } from 'web/lib/firebase/contracts' import { db } from 'web/lib/firebase/init' import { filterDefined } from 'common/util/array' import { getUser } from 'web/lib/firebase/users' @@ -31,6 +31,9 @@ export const groupMembers = (groupId: string) => collection(groups, groupId, 'groupMembers') export const groupContracts = (groupId: string) => collection(groups, groupId, 'groupContracts') +const openGroupsQuery = query(groups, where('anyoneCanJoin', '==', true)) +const memberGroupsQuery = (userId: string) => + query(collectionGroup(db, 'groupMembers'), where('userId', '==', userId)) export function groupPath( groupSlug: string, @@ -78,23 +81,24 @@ export function listenForGroupContractDocs( return listenForValues(groupContracts(groupId), setContractDocs) } -export function listenForOpenGroups(setGroups: (groups: Group[]) => void) { - return listenForValues( - query(groups, where('anyoneCanJoin', '==', true)), - setGroups +export async function listGroupContracts(groupId: string) { + const contractDocs = await getValues<{ + contractId: string + createdTime: number + }>(groupContracts(groupId)) + return Promise.all( + contractDocs.map((doc) => getContractFromId(doc.contractId)) ) } +export function listenForOpenGroups(setGroups: (groups: Group[]) => void) { + return listenForValues(openGroupsQuery, setGroups) +} + export function getGroup(groupId: string) { return getValue<Group>(doc(groups, groupId)) } -export function getGroupContracts(groupId: string) { - return getValues<{ contractId: string; createdTime: number }>( - groupContracts(groupId) - ) -} - export async function getGroupBySlug(slug: string) { const q = query(groups, where('slug', '==', slug)) const docs = (await getDocs(q)).docs @@ -112,10 +116,7 @@ export function listenForMemberGroupIds( userId: string, setGroupIds: (groupIds: string[]) => void ) { - const q = query( - collectionGroup(db, 'groupMembers'), - where('userId', '==', userId) - ) + const q = memberGroupsQuery(userId) return onSnapshot(q, { includeMetadataChanges: true }, (snapshot) => { if (snapshot.metadata.fromCache) return @@ -136,6 +137,24 @@ export function listenForMemberGroups( }) } +export async function listAvailableGroups(userId: string) { + const [openGroups, memberGroupSnapshot] = await Promise.all([ + getValues<Group>(openGroupsQuery), + getDocs(memberGroupsQuery(userId)), + ]) + const memberGroups = filterDefined( + await Promise.all( + memberGroupSnapshot.docs.map((doc) => { + return doc.ref.parent.parent?.id + ? getGroup(doc.ref.parent.parent?.id) + : null + }) + ) + ) + + return uniqBy([...openGroups, ...memberGroups], (g) => g.id) +} + export async function addUserToGroupViaId(groupId: string, userId: string) { // get group to get the member ids const group = await getGroup(groupId) diff --git a/web/pages/api/v0/group/by-id/[id].ts b/web/pages/api/v0/group/by-id/[id]/index.ts similarity index 100% rename from web/pages/api/v0/group/by-id/[id].ts rename to web/pages/api/v0/group/by-id/[id]/index.ts diff --git a/web/pages/api/v0/group/by-id/[id]/markets.ts b/web/pages/api/v0/group/by-id/[id]/markets.ts new file mode 100644 index 00000000..f7538277 --- /dev/null +++ b/web/pages/api/v0/group/by-id/[id]/markets.ts @@ -0,0 +1,18 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' +import { listGroupContracts } from 'web/lib/firebase/groups' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + const { id } = req.query + const contracts = await listGroupContracts(id as string) + if (!contracts) { + res.status(404).json({ error: 'Group not found' }) + return + } + res.setHeader('Cache-Control', 'no-cache') + return res.status(200).json(contracts) +} diff --git a/web/pages/api/v0/groups.ts b/web/pages/api/v0/groups.ts index 84b773b3..60d94c1c 100644 --- a/web/pages/api/v0/groups.ts +++ b/web/pages/api/v0/groups.ts @@ -1,14 +1,42 @@ import type { NextApiRequest, NextApiResponse } from 'next' -import { listAllGroups } from 'web/lib/firebase/groups' +import { listAllGroups, listAvailableGroups } from 'web/lib/firebase/groups' import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' +import { z } from 'zod' +import { validate } from 'web/pages/api/v0/_validate' +import { ValidationError } from 'web/pages/api/v0/_types' -type Data = any[] +const queryParams = z + .object({ + availableToUserId: z.string().optional(), + }) + .strict() export default async function handler( req: NextApiRequest, - res: NextApiResponse<Data> + res: NextApiResponse ) { await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + let params: z.infer<typeof queryParams> + try { + params = validate(queryParams, req.query) + } catch (e) { + if (e instanceof ValidationError) { + return res.status(400).json(e) + } + console.error(`Unknown error during validation: ${e}`) + return res.status(500).json({ error: 'Unknown error during validation' }) + } + + const { availableToUserId } = params + + // TODO: should we check if the user is a real user? + if (availableToUserId) { + const groups = await listAvailableGroups(availableToUserId) + res.setHeader('Cache-Control', 'max-age=0') + res.status(200).json(groups) + return + } + const groups = await listAllGroups() res.setHeader('Cache-Control', 'max-age=0') res.status(200).json(groups) From 7c44abdcd712cbd2ac07fb77fe018111b80cceca Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 09:27:50 -0600 Subject: [PATCH 73/82] Comment out unused script functions --- functions/src/scripts/update-groups.ts | 150 ++++++++++++------------- 1 file changed, 75 insertions(+), 75 deletions(-) diff --git a/functions/src/scripts/update-groups.ts b/functions/src/scripts/update-groups.ts index 05666ab5..fc402292 100644 --- a/functions/src/scripts/update-groups.ts +++ b/functions/src/scripts/update-groups.ts @@ -9,83 +9,83 @@ const getGroups = async () => { return groups.docs.map((doc) => doc.data() as Group) } -const createContractIdForGroup = async ( - groupId: string, - contractId: string -) => { - const firestore = admin.firestore() - const now = Date.now() - const contractDoc = await firestore - .collection('groups') - .doc(groupId) - .collection('groupContracts') - .doc(contractId) - .get() - if (!contractDoc.exists) - await firestore - .collection('groups') - .doc(groupId) - .collection('groupContracts') - .doc(contractId) - .create({ - contractId, - createdTime: now, - }) -} +// const createContractIdForGroup = async ( +// groupId: string, +// contractId: string +// ) => { +// const firestore = admin.firestore() +// const now = Date.now() +// const contractDoc = await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupContracts') +// .doc(contractId) +// .get() +// if (!contractDoc.exists) +// await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupContracts') +// .doc(contractId) +// .create({ +// contractId, +// createdTime: now, +// }) +// } -const createMemberForGroup = async (groupId: string, userId: string) => { - const firestore = admin.firestore() - const now = Date.now() - const memberDoc = await firestore - .collection('groups') - .doc(groupId) - .collection('groupMembers') - .doc(userId) - .get() - if (!memberDoc.exists) - await firestore - .collection('groups') - .doc(groupId) - .collection('groupMembers') - .doc(userId) - .create({ - userId, - createdTime: now, - }) -} +// const createMemberForGroup = async (groupId: string, userId: string) => { +// const firestore = admin.firestore() +// const now = Date.now() +// const memberDoc = await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupMembers') +// .doc(userId) +// .get() +// if (!memberDoc.exists) +// await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupMembers') +// .doc(userId) +// .create({ +// userId, +// createdTime: now, +// }) +// } + +// async function convertGroupFieldsToGroupDocuments() { +// const groups = await getGroups() +// for (const group of groups) { +// log('updating group', group.slug) +// const groupRef = admin.firestore().collection('groups').doc(group.id) +// const totalMembers = (await groupRef.collection('groupMembers').get()).size +// const totalContracts = (await groupRef.collection('groupContracts').get()) +// .size +// if ( +// totalMembers === group.memberIds?.length && +// totalContracts === group.contractIds?.length +// ) { +// log('group already converted', group.slug) +// continue +// } +// const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1 +// const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1 +// for (const contractId of group.contractIds?.slice( +// contractStart, +// group.contractIds?.length +// ) ?? []) { +// await createContractIdForGroup(group.id, contractId) +// } +// for (const userId of group.memberIds?.slice( +// membersStart, +// group.memberIds?.length +// ) ?? []) { +// await createMemberForGroup(group.id, userId) +// } +// } +// } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function convertGroupFieldsToGroupDocuments() { - const groups = await getGroups() - for (const group of groups) { - log('updating group', group.slug) - const groupRef = admin.firestore().collection('groups').doc(group.id) - const totalMembers = (await groupRef.collection('groupMembers').get()).size - const totalContracts = (await groupRef.collection('groupContracts').get()) - .size - if ( - totalMembers === group.memberIds?.length && - totalContracts === group.contractIds?.length - ) { - log('group already converted', group.slug) - continue - } - const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1 - const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1 - for (const contractId of group.contractIds?.slice( - contractStart, - group.contractIds?.length - ) ?? []) { - await createContractIdForGroup(group.id, contractId) - } - for (const userId of group.memberIds?.slice( - membersStart, - group.memberIds?.length - ) ?? []) { - await createMemberForGroup(group.id, userId) - } - } -} // eslint-disable-next-line @typescript-eslint/no-unused-vars async function updateTotalContractsAndMembers() { const groups = await getGroups() From 74af54f3c058863fee09badcb1d79c68ff1a67a1 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 09:36:41 -0600 Subject: [PATCH 74/82] Remove chance from FR og-images --- og-image/api/_lib/template.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index 2469a636..f8e235b7 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -118,7 +118,9 @@ export function getHtml(parsedReq: ParsedRequest) { ? resolutionDiv : numericValue ? numericValueDiv - : probabilityDiv + : probability + ? probabilityDiv + : '' } </div> </div> From a038ef91eb2ef001dea84ca6122c432290b84605 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 09:58:24 -0600 Subject: [PATCH 75/82] Show num contracts in group selector --- web/components/groups/group-selector.tsx | 29 ++++++++++++++---------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/web/components/groups/group-selector.tsx b/web/components/groups/group-selector.tsx index d48256a6..344339d1 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, useOpenGroups } from 'web/hooks/use-group' +import { useMemberGroups } from 'web/hooks/use-group' import { User } from 'common/user' import { searchInAny } from 'common/util/parse' @@ -27,14 +27,9 @@ export function GroupSelector(props: { const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false) const { showSelector, showLabel, ignoreGroupIds } = options const [query, setQuery] = useState('') - 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 availableGroups = (useMemberGroups(creator?.id) ?? []).filter( + (group) => !ignoreGroupIds?.includes(group.id) + ) const filteredGroups = availableGroups.filter((group) => searchInAny(query, group.name) ) @@ -96,7 +91,7 @@ export function GroupSelector(props: { value={group} className={({ active }) => clsx( - 'relative h-12 cursor-pointer select-none py-2 pl-4 pr-9', + 'relative h-12 cursor-pointer select-none py-2 pl-4 pr-6', active ? 'bg-indigo-500 text-white' : 'text-gray-900' ) } @@ -115,11 +110,21 @@ export function GroupSelector(props: { )} <span className={clsx( - 'ml-5 mt-1 block truncate', + 'ml-3 mt-1 block flex flex-row justify-between', selected && 'font-semibold' )} > - {group.name} + <span className={'truncate'}>{group.name}</span> + <span + className={clsx( + 'ml-1 w-[1.4rem] shrink-0 rounded-full bg-indigo-500 text-center text-white', + group.totalContracts > 99 ? 'w-[2.1rem]' : '' + )} + > + {group.totalContracts > 99 + ? '99+' + : group.totalContracts} + </span> </span> </> )} From c59de1be2e321ac5506015d5b47bbd84fe93c2f6 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 6 Sep 2022 11:53:09 -0500 Subject: [PATCH 76/82] bet slider: decrease step size --- web/components/amount-input.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 08a9720a..9eff26ef 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -138,11 +138,11 @@ export function BuyAmountInput(props: { <input type="range" min="0" - max="250" + max="200" value={amount ?? 0} onChange={(e) => onAmountChange(parseInt(e.target.value))} className="range range-lg z-40 mb-2 xl:hidden" - step="25" + step="5" /> )} </> From 45e54789b72e8402cecc86e1d1764bdef2a51c69 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 15:51:36 -0600 Subject: [PATCH 77/82] Groups search shares query, sorted by contract & members --- web/components/groups/group-selector.tsx | 14 ++++++++++---- web/pages/groups.tsx | 23 +++++++---------------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/web/components/groups/group-selector.tsx b/web/components/groups/group-selector.tsx index 344339d1..a75a0a34 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,9 +27,15 @@ export function GroupSelector(props: { const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false) const { showSelector, showLabel, ignoreGroupIds } = options const [query, setQuery] = useState('') - const availableGroups = (useMemberGroups(creator?.id) ?? []).filter( - (group) => !ignoreGroupIds?.includes(group.id) - ) + 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/pages/groups.tsx b/web/pages/groups.tsx index 3405ef3e..f39a7647 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -65,20 +65,9 @@ export default function Groups(props: { const [query, setQuery] = useState('') - // List groups with the highest question count, then highest member count - // TODO use find-active-contracts to sort by? - const matches = sortBy(groups, []).filter((g) => - searchInAny( - query, - g.name, - g.about || '', - creatorsDict[g.creatorId].username - ) - ) - - const matchesOrderedByRecentActivity = sortBy(groups, [ - (group) => - -1 * (group.mostRecentContractAddedTime ?? group.mostRecentActivityTime), + const matchesOrderedByMostContractAndMembers = sortBy(groups, [ + (group) => -1 * group.totalContracts, + (group) => -1 * group.totalMembers, ]).filter((g) => searchInAny( query, @@ -120,13 +109,14 @@ export default function Groups(props: { <Col> <input type="text" + value={query} onChange={(e) => debouncedQuery(e.target.value)} placeholder="Search your groups" className="input input-bordered mb-4 w-full" /> <div className="flex flex-wrap justify-center gap-4"> - {matchesOrderedByRecentActivity + {matchesOrderedByMostContractAndMembers .filter((match) => memberGroupIds.includes(match.id) ) @@ -153,11 +143,12 @@ export default function Groups(props: { type="text" onChange={(e) => debouncedQuery(e.target.value)} placeholder="Search groups" + value={query} className="input input-bordered mb-4 w-full" /> <div className="flex flex-wrap justify-center gap-4"> - {matches.map((group) => ( + {matchesOrderedByMostContractAndMembers.map((group) => ( <GroupCard key={group.id} group={group} From 668f30dd55965e652b8359ac362b5cb4b5435b4a Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 6 Sep 2022 16:55:43 -0500 Subject: [PATCH 78/82] Free market creation shows cost striked through --- web/pages/create.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 7e1ead90..1f1a006b 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -483,17 +483,17 @@ export function NewContract(props: { {formatMoney(ante)} </div> ) : ( - <div> - <div className="label-text text-primary pl-1"> - FREE{' '} - <span className="label-text pl-1 text-gray-500"> - (You have{' '} - {FREE_MARKETS_PER_USER_MAX - - (creator?.freeMarketsCreated ?? 0)}{' '} - free markets left) - </span> + <Row> + <div className="label-text text-neutral pl-1 line-through"> + {formatMoney(ante)} </div> - </div> + <div className="label-text text-primary pl-1">FREE </div> + <div className="label-text pl-1 text-gray-500"> + (You have{' '} + {FREE_MARKETS_PER_USER_MAX - (creator?.freeMarketsCreated ?? 0)}{' '} + free markets left) + </div> + </Row> )} {ante > balance && !deservesFreeMarket && ( From c16e7c6cfd652e9f7263d265ac0c826db3fafcb5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 16:20:43 -0600 Subject: [PATCH 79/82] Add membership indicators and link to see group --- web/components/groups/group-selector.tsx | 28 +++++++++++++++++++++--- web/pages/create.tsx | 24 +++++++++++++------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/web/components/groups/group-selector.tsx b/web/components/groups/group-selector.tsx index a75a0a34..54fc0764 100644 --- a/web/components/groups/group-selector.tsx +++ b/web/components/groups/group-selector.tsx @@ -5,6 +5,7 @@ import { CheckIcon, PlusCircleIcon, SelectorIcon, + UserIcon, } from '@heroicons/react/outline' import clsx from 'clsx' import { CreateGroupButton } from 'web/components/groups/create-group-button' @@ -12,6 +13,7 @@ import { useState } from 'react' import { useMemberGroups, useOpenGroups } from 'web/hooks/use-group' import { User } from 'common/user' import { searchInAny } from 'common/util/parse' +import { Row } from 'web/components/layout/row' export function GroupSelector(props: { selectedGroup: Group | undefined @@ -28,13 +30,26 @@ export function GroupSelector(props: { const { showSelector, showLabel, ignoreGroupIds } = options const [query, setQuery] = useState('') const openGroups = useOpenGroups() + const memberGroups = useMemberGroups(creator?.id) + const memberGroupIds = memberGroups?.map((g) => g.id) ?? [] const availableGroups = openGroups .concat( - (useMemberGroups(creator?.id) ?? []).filter( + (memberGroups ?? []).filter( (g) => !openGroups.map((og) => og.id).includes(g.id) ) ) .filter((group) => !ignoreGroupIds?.includes(group.id)) + .sort((a, b) => b.totalContracts - a.totalContracts) + // put the groups the user is a member of first + .sort((a, b) => { + if (memberGroupIds.includes(a.id)) { + return -1 + } + if (memberGroupIds.includes(b.id)) { + return 1 + } + return 0 + }) const filteredGroups = availableGroups.filter((group) => searchInAny(query, group.name) @@ -97,7 +112,7 @@ export function GroupSelector(props: { value={group} className={({ active }) => clsx( - 'relative h-12 cursor-pointer select-none py-2 pl-4 pr-6', + 'relative h-12 cursor-pointer select-none py-2 pr-6', active ? 'bg-indigo-500 text-white' : 'text-gray-900' ) } @@ -120,7 +135,14 @@ export function GroupSelector(props: { selected && 'font-semibold' )} > - <span className={'truncate'}>{group.name}</span> + <Row className={'items-center gap-1 truncate pl-5'}> + {memberGroupIds.includes(group.id) && ( + <UserIcon + className={'text-primary h-4 w-4 shrink-0'} + /> + )} + {group.name} + </Row> <span className={clsx( 'ml-1 w-[1.4rem] shrink-0 rounded-full bg-indigo-500 text-center text-white', diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 1f1a006b..5fb9549e 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -20,7 +20,7 @@ import { import { formatMoney } from 'common/util/format' import { removeUndefinedProps } from 'common/util/object' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { getGroup } from 'web/lib/firebase/groups' +import { getGroup, groupPath } 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' @@ -34,6 +34,8 @@ import { Title } from 'web/components/title' import { SEO } from 'web/components/SEO' import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers' import { MINUTE_MS } from 'common/util/time' +import { ExternalLinkIcon } from '@heroicons/react/outline' +import { SiteLink } from 'web/components/site-link' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { return { props: { auth: await getUserAndPrivateUser(creds.uid) } } @@ -406,13 +408,19 @@ export function NewContract(props: { <Spacer h={6} /> - <GroupSelector - selectedGroup={selectedGroup} - setSelectedGroup={setSelectedGroup} - creator={creator} - options={{ showSelector: showGroupSelector, showLabel: true }} - /> - + <Row className={'items-end gap-x-2'}> + <GroupSelector + selectedGroup={selectedGroup} + setSelectedGroup={setSelectedGroup} + creator={creator} + options={{ showSelector: showGroupSelector, showLabel: true }} + /> + {showGroupSelector && selectedGroup && ( + <SiteLink href={groupPath(selectedGroup.slug)}> + <ExternalLinkIcon className=" ml-1 mb-3 h-5 w-5 text-gray-500" /> + </SiteLink> + )} + </Row> <Spacer h={6} /> <div className="form-control mb-1 items-start"> From 8759064ccb7f4cea4eef9a7a9524952383e91853 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 16:30:58 -0600 Subject: [PATCH 80/82] new bettors --- web/pages/notifications.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 2ec3ac6f..ccfbf371 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -390,7 +390,7 @@ function IncomeNotificationItem(props: { reasonText = !simple ? `Bonus for ${ parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT - } unique traders on` + } new bettors on` : 'bonus on' } else if (sourceType === 'tip') { reasonText = !simple ? `tipped you on` : `in tips on` From f7d027ccc99b6e3f88aa00721301679ad70f2b54 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 16:38:01 -0600 Subject: [PATCH 81/82] Create button=>Site link --- web/components/create-question-button.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/components/create-question-button.tsx b/web/components/create-question-button.tsx index 20225b78..d9146f1a 100644 --- a/web/components/create-question-button.tsx +++ b/web/components/create-question-button.tsx @@ -1,13 +1,13 @@ import React from 'react' -import Link from 'next/link' import { Button } from './button' +import { SiteLink } from 'web/components/site-link' export const CreateQuestionButton = () => { return ( - <Link href="/create" passHref> - <Button color="gradient" size="xl" className="mt-4"> + <SiteLink href="/create"> + <Button color="gradient" size="xl" className="mt-4 w-full"> Create a market </Button> - </Link> + </SiteLink> ) } From 537962a7dc233e6ac67307734d71dd0672e1a8cc Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Tue, 6 Sep 2022 16:55:33 -0700 Subject: [PATCH 82/82] Stop links from opening twice --- web/components/editor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/editor.tsx b/web/components/editor.tsx index c15d17b1..b36571ba 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -254,7 +254,7 @@ export function RichContent(props: { extensions: [ StarterKit, smallImage ? DisplayImage : Image, - DisplayLink, + DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) DisplayMention, Iframe, TiptapTweet,