diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx new file mode 100644 index 00000000..fcc3de39 --- /dev/null +++ b/web/components/auth-context.tsx @@ -0,0 +1,77 @@ +import { createContext, useEffect } from 'react' +import { User } from 'common/user' +import { onIdTokenChanged } from 'firebase/auth' +import { + auth, + listenForUser, + getUser, + setCachedReferralInfoForUser, +} from 'web/lib/firebase/users' +import { deleteAuthCookies, setAuthCookies } from 'web/lib/firebase/auth' +import { createUser } from 'web/lib/firebase/api' +import { randomString } from 'common/util/random' +import { identifyUser, setUserProperty } from 'web/lib/service/analytics' +import { useStateCheckEquality } from 'web/hooks/use-state-check-equality' + +// Either we haven't looked up the logged in user yet (undefined), or we know +// the user is not logged in (null), or we know the user is logged in (User). +type AuthUser = undefined | null | User + +const CACHED_USER_KEY = 'CACHED_USER_KEY' + +const ensureDeviceToken = () => { + let deviceToken = localStorage.getItem('device-token') + if (!deviceToken) { + deviceToken = randomString() + localStorage.setItem('device-token', deviceToken) + } + return deviceToken +} + +export const AuthContext = createContext(null) + +export function AuthProvider({ children }: any) { + const [authUser, setAuthUser] = useStateCheckEquality(undefined) + + useEffect(() => { + const cachedUser = localStorage.getItem(CACHED_USER_KEY) + setAuthUser(cachedUser && JSON.parse(cachedUser)) + }, [setAuthUser]) + + useEffect(() => { + return onIdTokenChanged(auth, async (fbUser) => { + if (fbUser) { + let user = await getUser(fbUser.uid) + if (!user) { + const deviceToken = ensureDeviceToken() + user = (await createUser({ deviceToken })) as User + } + setAuthUser(user) + // Persist to local storage, to reduce login blink next time. + // Note: Cap on localStorage size is ~5mb + localStorage.setItem(CACHED_USER_KEY, JSON.stringify(user)) + setCachedReferralInfoForUser(user) + setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken) + } else { + // User logged out; reset to null + setAuthUser(null) + localStorage.removeItem(CACHED_USER_KEY) + deleteAuthCookies() + } + }) + }, [setAuthUser]) + + const authUserId = authUser?.id + const authUsername = authUser?.username + useEffect(() => { + if (authUserId && authUsername) { + identifyUser(authUserId) + setUserProperty('username', authUsername) + return listenForUser(authUserId, setAuthUser) + } + }, [authUserId, authUsername, setAuthUser]) + + return ( + {children} + ) +} diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 2114ec2b..a306a020 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -78,10 +78,10 @@ export function BetsList(props: { const getTime = useTimeSinceFirstRender() useEffect(() => { - if (bets && contractsById) { - trackLatency('portfolio', getTime()) + if (bets && contractsById && signedInUser) { + trackLatency(signedInUser.id, 'portfolio', getTime()) } - }, [bets, contractsById, getTime]) + }, [signedInUser, bets, contractsById, getTime]) if (!bets || !contractsById) { return diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index ff5f5440..ea8302b8 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -23,6 +23,7 @@ import BetRow from '../bet-row' import { Avatar } from '../avatar' import { ActivityItem } from './activity-items' import { useSaveSeenContract } from 'web/hooks/use-seen-contracts' +import { useUser } from 'web/hooks/use-user' import { trackClick } from 'web/lib/firebase/tracking' import { DAY_MS } from 'common/util/time' import NewContractBadge from '../new-contract-badge' @@ -118,6 +119,7 @@ export function FeedQuestion(props: { const { volumeLabel } = contractMetrics(contract) const isBinary = outcomeType === 'BINARY' const isNew = createdTime > Date.now() - DAY_MS && !isResolved + const user = useUser() return (
@@ -149,7 +151,7 @@ export function FeedQuestion(props: { href={ props.contractPath ? props.contractPath : contractPath(contract) } - onClick={() => trackClick(contract.id)} + onClick={() => user && trackClick(user.id, contract.id)} className="text-lg text-indigo-700 sm:text-xl" > {question} diff --git a/web/hooks/use-algo-feed.ts b/web/hooks/use-algo-feed.ts index fde50e80..e195936f 100644 --- a/web/hooks/use-algo-feed.ts +++ b/web/hooks/use-algo-feed.ts @@ -25,7 +25,7 @@ export const useAlgoFeed = ( getDefaultFeed().then((feed) => setAllFeed(feed)) } else setAllFeed(feed) - trackLatency('feed', getTime()) + trackLatency(user.id, 'feed', getTime()) console.log('"all" feed load time', getTime()) }) diff --git a/web/hooks/use-seen-contracts.ts b/web/hooks/use-seen-contracts.ts index 501e7b0c..d21ca84c 100644 --- a/web/hooks/use-seen-contracts.ts +++ b/web/hooks/use-seen-contracts.ts @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { Contract } from 'common/contract' import { trackView } from 'web/lib/firebase/tracking' import { useIsVisible } from './use-is-visible' +import { useUser } from './use-user' export const useSeenContracts = () => { const [seenContracts, setSeenContracts] = useState<{ @@ -21,18 +22,19 @@ export const useSaveSeenContract = ( contract: Contract ) => { const isVisible = useIsVisible(elem) + const user = useUser() useEffect(() => { - if (isVisible) { + if (isVisible && user) { const newSeenContracts = { ...getSeenContracts(), [contract.id]: Date.now(), } localStorage.setItem(key, JSON.stringify(newSeenContracts)) - trackView(contract.id) + trackView(user.id, contract.id) } - }, [isVisible, contract]) + }, [isVisible, user, contract]) } const key = 'feed-seen-contracts' diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index e04a69ca..4c492d6c 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useContext, useEffect, useState } from 'react' import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import { QueryClient } from 'react-query' @@ -6,32 +6,14 @@ import { doc, DocumentData } from 'firebase/firestore' import { PrivateUser } from 'common/user' import { getUser, - listenForLogin, listenForPrivateUser, - listenForUser, User, users, } from 'web/lib/firebase/users' -import { useStateCheckEquality } from './use-state-check-equality' -import { identifyUser, setUserProperty } from 'web/lib/service/analytics' +import { AuthContext } from 'web/components/auth-context' export const useUser = () => { - const [user, setUser] = useStateCheckEquality( - undefined - ) - - useEffect(() => listenForLogin(setUser), [setUser]) - - useEffect(() => { - if (user) { - identifyUser(user.id) - setUserProperty('username', user.username) - - return listenForUser(user.id, setUser) - } - }, [user, setUser]) - - return user + return useContext(AuthContext) } export const usePrivateUser = (userId?: string) => { diff --git a/web/lib/firebase/tracking.ts b/web/lib/firebase/tracking.ts index f6ad3aa8..d1828e01 100644 --- a/web/lib/firebase/tracking.ts +++ b/web/lib/firebase/tracking.ts @@ -2,16 +2,9 @@ import { doc, collection, setDoc } from 'firebase/firestore' import { db } from './init' import { ClickEvent, LatencyEvent, View } from 'common/tracking' -import { listenForLogin, User } from './users' -let user: User | null = null -if (typeof window !== 'undefined') { - listenForLogin((u) => (user = u)) -} - -export async function trackView(contractId: string) { - if (!user) return - const ref = doc(collection(db, 'private-users', user.id, 'views')) +export async function trackView(userId: string, contractId: string) { + const ref = doc(collection(db, 'private-users', userId, 'views')) const view: View = { contractId, @@ -21,9 +14,8 @@ export async function trackView(contractId: string) { return await setDoc(ref, view) } -export async function trackClick(contractId: string) { - if (!user) return - const ref = doc(collection(db, 'private-users', user.id, 'events')) +export async function trackClick(userId: string, contractId: string) { + const ref = doc(collection(db, 'private-users', userId, 'events')) const clickEvent: ClickEvent = { type: 'click', @@ -35,11 +27,11 @@ export async function trackClick(contractId: string) { } export async function trackLatency( + userId: string, type: 'feed' | 'portfolio', latency: number ) { - if (!user) return - const ref = doc(collection(db, 'private-users', user.id, 'latency')) + const ref = doc(collection(db, 'private-users', userId, 'latency')) const latencyEvent: LatencyEvent = { type, diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 89852851..481f86de 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -15,15 +15,10 @@ import { } from 'firebase/firestore' import { getAuth } from 'firebase/auth' import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage' -import { - onIdTokenChanged, - GoogleAuthProvider, - signInWithPopup, -} from 'firebase/auth' +import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth' import { zip } from 'lodash' import { app, db } from './init' import { PortfolioMetrics, PrivateUser, User } from 'common/user' -import { createUser } from './api' import { coll, getValue, @@ -37,13 +32,11 @@ import { safeLocalStorage } from '../util/local' import { filterDefined } from 'common/util/array' import { addUserToGroupViaId } from 'web/lib/firebase/groups' import { removeUndefinedProps } from 'common/util/object' -import { randomString } from 'common/util/random' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' dayjs.extend(utc) import { track } from '@amplitude/analytics-browser' -import { deleteAuthCookies, setAuthCookies } from './auth' export const users = coll('users') export const privateUsers = coll('private-users') @@ -97,7 +90,6 @@ export function listenForPrivateUser( return listenForValue(userRef, setPrivateUser) } -const CACHED_USER_KEY = 'CACHED_USER_KEY' const CACHED_REFERRAL_USERNAME_KEY = 'CACHED_REFERRAL_KEY' const CACHED_REFERRAL_CONTRACT_ID_KEY = 'CACHED_REFERRAL_CONTRACT_KEY' const CACHED_REFERRAL_GROUP_ID_KEY = 'CACHED_REFERRAL_GROUP_KEY' @@ -130,7 +122,7 @@ export function writeReferralInfo( local?.setItem(CACHED_REFERRAL_CONTRACT_ID_KEY, contractId) } -async function setCachedReferralInfoForUser(user: User | null) { +export async function setCachedReferralInfoForUser(user: User | null) { if (!user || user.referredByUserId) return // if the user wasn't created in the last minute, don't bother const now = dayjs().utc() @@ -181,46 +173,6 @@ async function setCachedReferralInfoForUser(user: User | null) { local?.removeItem(CACHED_REFERRAL_CONTRACT_ID_KEY) } -// used to avoid weird race condition -let createUserPromise: Promise | undefined = undefined - -export function listenForLogin(onUser: (user: User | null) => void) { - const local = safeLocalStorage() - const cachedUser = local?.getItem(CACHED_USER_KEY) - onUser(cachedUser && JSON.parse(cachedUser)) - - return onIdTokenChanged(auth, async (fbUser) => { - if (fbUser) { - let user: User | null = await getUser(fbUser.uid) - if (!user) { - if (createUserPromise == null) { - const local = safeLocalStorage() - let deviceToken = local?.getItem('device-token') - if (!deviceToken) { - deviceToken = randomString() - local?.setItem('device-token', deviceToken) - } - createUserPromise = createUser({ deviceToken }).then((r) => r as User) - } - user = await createUserPromise - } - onUser(user) - - // Persist to local storage, to reduce login blink next time. - // Note: Cap on localStorage size is ~5mb - local?.setItem(CACHED_USER_KEY, JSON.stringify(user)) - setCachedReferralInfoForUser(user) - setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken) - } else { - // User logged out; reset to null - onUser(null) - createUserPromise = undefined - local?.removeItem(CACHED_USER_KEY) - deleteAuthCookies() - } - }) -} - export async function firebaseLogin() { const provider = new GoogleAuthProvider() return signInWithPopup(auth, provider) diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index d081bc9a..52316eb0 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -5,6 +5,7 @@ import Head from 'next/head' import Script from 'next/script' import { usePreserveScroll } from 'web/hooks/use-preserve-scroll' import { QueryClient, QueryClientProvider } from 'react-query' +import { AuthProvider } from 'web/components/auth-context' function firstLine(msg: string) { return msg.replace(/\r?\n.*/s, '') @@ -78,9 +79,11 @@ function MyApp({ Component, pageProps }: AppProps) { /> - - - + + + + + ) }