Make a React context to be the source of truth for authenticated user (#675)

* Make a React context to manage the logged in user events

* Remove unnecessary new user creation promise machinery

* Slight refactoring to auth context code

* Improvements in response to James feedback
This commit is contained in:
Marshall Polaris 2022-07-21 00:38:26 -07:00 committed by GitHub
parent 8aa360c853
commit 03858e4a8c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 106 additions and 96 deletions

View File

@ -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<AuthUser>(null)
export function AuthProvider({ children }: any) {
const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(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 (
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>
)
}

View File

@ -78,10 +78,10 @@ export function BetsList(props: {
const getTime = useTimeSinceFirstRender() const getTime = useTimeSinceFirstRender()
useEffect(() => { useEffect(() => {
if (bets && contractsById) { if (bets && contractsById && signedInUser) {
trackLatency('portfolio', getTime()) trackLatency(signedInUser.id, 'portfolio', getTime())
} }
}, [bets, contractsById, getTime]) }, [signedInUser, bets, contractsById, getTime])
if (!bets || !contractsById) { if (!bets || !contractsById) {
return <LoadingIndicator /> return <LoadingIndicator />

View File

@ -23,6 +23,7 @@ import BetRow from '../bet-row'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import { ActivityItem } from './activity-items' import { ActivityItem } from './activity-items'
import { useSaveSeenContract } from 'web/hooks/use-seen-contracts' import { useSaveSeenContract } from 'web/hooks/use-seen-contracts'
import { useUser } from 'web/hooks/use-user'
import { trackClick } from 'web/lib/firebase/tracking' import { trackClick } from 'web/lib/firebase/tracking'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import NewContractBadge from '../new-contract-badge' import NewContractBadge from '../new-contract-badge'
@ -118,6 +119,7 @@ export function FeedQuestion(props: {
const { volumeLabel } = contractMetrics(contract) const { volumeLabel } = contractMetrics(contract)
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const isNew = createdTime > Date.now() - DAY_MS && !isResolved const isNew = createdTime > Date.now() - DAY_MS && !isResolved
const user = useUser()
return ( return (
<div className={'flex gap-2'}> <div className={'flex gap-2'}>
@ -149,7 +151,7 @@ export function FeedQuestion(props: {
href={ href={
props.contractPath ? props.contractPath : contractPath(contract) 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" className="text-lg text-indigo-700 sm:text-xl"
> >
{question} {question}

View File

@ -25,7 +25,7 @@ export const useAlgoFeed = (
getDefaultFeed().then((feed) => setAllFeed(feed)) getDefaultFeed().then((feed) => setAllFeed(feed))
} else setAllFeed(feed) } else setAllFeed(feed)
trackLatency('feed', getTime()) trackLatency(user.id, 'feed', getTime())
console.log('"all" feed load time', getTime()) console.log('"all" feed load time', getTime())
}) })

View File

@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { trackView } from 'web/lib/firebase/tracking' import { trackView } from 'web/lib/firebase/tracking'
import { useIsVisible } from './use-is-visible' import { useIsVisible } from './use-is-visible'
import { useUser } from './use-user'
export const useSeenContracts = () => { export const useSeenContracts = () => {
const [seenContracts, setSeenContracts] = useState<{ const [seenContracts, setSeenContracts] = useState<{
@ -21,18 +22,19 @@ export const useSaveSeenContract = (
contract: Contract contract: Contract
) => { ) => {
const isVisible = useIsVisible(elem) const isVisible = useIsVisible(elem)
const user = useUser()
useEffect(() => { useEffect(() => {
if (isVisible) { if (isVisible && user) {
const newSeenContracts = { const newSeenContracts = {
...getSeenContracts(), ...getSeenContracts(),
[contract.id]: Date.now(), [contract.id]: Date.now(),
} }
localStorage.setItem(key, JSON.stringify(newSeenContracts)) localStorage.setItem(key, JSON.stringify(newSeenContracts))
trackView(contract.id) trackView(user.id, contract.id)
} }
}, [isVisible, contract]) }, [isVisible, user, contract])
} }
const key = 'feed-seen-contracts' const key = 'feed-seen-contracts'

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useContext, useEffect, useState } from 'react'
import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
import { QueryClient } from 'react-query' import { QueryClient } from 'react-query'
@ -6,32 +6,14 @@ import { doc, DocumentData } from 'firebase/firestore'
import { PrivateUser } from 'common/user' import { PrivateUser } from 'common/user'
import { import {
getUser, getUser,
listenForLogin,
listenForPrivateUser, listenForPrivateUser,
listenForUser,
User, User,
users, users,
} from 'web/lib/firebase/users' } from 'web/lib/firebase/users'
import { useStateCheckEquality } from './use-state-check-equality' import { AuthContext } from 'web/components/auth-context'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
export const useUser = () => { export const useUser = () => {
const [user, setUser] = useStateCheckEquality<User | null | undefined>( return useContext(AuthContext)
undefined
)
useEffect(() => listenForLogin(setUser), [setUser])
useEffect(() => {
if (user) {
identifyUser(user.id)
setUserProperty('username', user.username)
return listenForUser(user.id, setUser)
}
}, [user, setUser])
return user
} }
export const usePrivateUser = (userId?: string) => { export const usePrivateUser = (userId?: string) => {

View File

@ -2,16 +2,9 @@ import { doc, collection, setDoc } from 'firebase/firestore'
import { db } from './init' import { db } from './init'
import { ClickEvent, LatencyEvent, View } from 'common/tracking' import { ClickEvent, LatencyEvent, View } from 'common/tracking'
import { listenForLogin, User } from './users'
let user: User | null = null export async function trackView(userId: string, contractId: string) {
if (typeof window !== 'undefined') { const ref = doc(collection(db, 'private-users', userId, 'views'))
listenForLogin((u) => (user = u))
}
export async function trackView(contractId: string) {
if (!user) return
const ref = doc(collection(db, 'private-users', user.id, 'views'))
const view: View = { const view: View = {
contractId, contractId,
@ -21,9 +14,8 @@ export async function trackView(contractId: string) {
return await setDoc(ref, view) return await setDoc(ref, view)
} }
export async function trackClick(contractId: string) { export async function trackClick(userId: string, contractId: string) {
if (!user) return const ref = doc(collection(db, 'private-users', userId, 'events'))
const ref = doc(collection(db, 'private-users', user.id, 'events'))
const clickEvent: ClickEvent = { const clickEvent: ClickEvent = {
type: 'click', type: 'click',
@ -35,11 +27,11 @@ export async function trackClick(contractId: string) {
} }
export async function trackLatency( export async function trackLatency(
userId: string,
type: 'feed' | 'portfolio', type: 'feed' | 'portfolio',
latency: number latency: number
) { ) {
if (!user) return const ref = doc(collection(db, 'private-users', userId, 'latency'))
const ref = doc(collection(db, 'private-users', user.id, 'latency'))
const latencyEvent: LatencyEvent = { const latencyEvent: LatencyEvent = {
type, type,

View File

@ -15,15 +15,10 @@ import {
} from 'firebase/firestore' } from 'firebase/firestore'
import { getAuth } from 'firebase/auth' import { getAuth } from 'firebase/auth'
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage' import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
import { import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
onIdTokenChanged,
GoogleAuthProvider,
signInWithPopup,
} from 'firebase/auth'
import { zip } from 'lodash' import { zip } from 'lodash'
import { app, db } from './init' import { app, db } from './init'
import { PortfolioMetrics, PrivateUser, User } from 'common/user' import { PortfolioMetrics, PrivateUser, User } from 'common/user'
import { createUser } from './api'
import { import {
coll, coll,
getValue, getValue,
@ -37,13 +32,11 @@ import { safeLocalStorage } from '../util/local'
import { filterDefined } from 'common/util/array' import { filterDefined } from 'common/util/array'
import { addUserToGroupViaId } from 'web/lib/firebase/groups' import { addUserToGroupViaId } from 'web/lib/firebase/groups'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { randomString } from 'common/util/random'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc' import utc from 'dayjs/plugin/utc'
dayjs.extend(utc) dayjs.extend(utc)
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { deleteAuthCookies, setAuthCookies } from './auth'
export const users = coll<User>('users') export const users = coll<User>('users')
export const privateUsers = coll<PrivateUser>('private-users') export const privateUsers = coll<PrivateUser>('private-users')
@ -97,7 +90,6 @@ export function listenForPrivateUser(
return listenForValue<PrivateUser>(userRef, setPrivateUser) return listenForValue<PrivateUser>(userRef, setPrivateUser)
} }
const CACHED_USER_KEY = 'CACHED_USER_KEY'
const CACHED_REFERRAL_USERNAME_KEY = 'CACHED_REFERRAL_KEY' const CACHED_REFERRAL_USERNAME_KEY = 'CACHED_REFERRAL_KEY'
const CACHED_REFERRAL_CONTRACT_ID_KEY = 'CACHED_REFERRAL_CONTRACT_KEY' const CACHED_REFERRAL_CONTRACT_ID_KEY = 'CACHED_REFERRAL_CONTRACT_KEY'
const CACHED_REFERRAL_GROUP_ID_KEY = 'CACHED_REFERRAL_GROUP_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) 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 (!user || user.referredByUserId) return
// if the user wasn't created in the last minute, don't bother // if the user wasn't created in the last minute, don't bother
const now = dayjs().utc() const now = dayjs().utc()
@ -181,46 +173,6 @@ async function setCachedReferralInfoForUser(user: User | null) {
local?.removeItem(CACHED_REFERRAL_CONTRACT_ID_KEY) local?.removeItem(CACHED_REFERRAL_CONTRACT_ID_KEY)
} }
// used to avoid weird race condition
let createUserPromise: Promise<User> | 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() { export async function firebaseLogin() {
const provider = new GoogleAuthProvider() const provider = new GoogleAuthProvider()
return signInWithPopup(auth, provider) return signInWithPopup(auth, provider)

View File

@ -5,6 +5,7 @@ import Head from 'next/head'
import Script from 'next/script' import Script from 'next/script'
import { usePreserveScroll } from 'web/hooks/use-preserve-scroll' import { usePreserveScroll } from 'web/hooks/use-preserve-scroll'
import { QueryClient, QueryClientProvider } from 'react-query' import { QueryClient, QueryClientProvider } from 'react-query'
import { AuthProvider } from 'web/components/auth-context'
function firstLine(msg: string) { function firstLine(msg: string) {
return msg.replace(/\r?\n.*/s, '') return msg.replace(/\r?\n.*/s, '')
@ -78,9 +79,11 @@ function MyApp({ Component, pageProps }: AppProps) {
/> />
</Head> </Head>
<AuthProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Component {...pageProps} /> <Component {...pageProps} />
</QueryClientProvider> </QueryClientProvider>
</AuthProvider>
</> </>
) )
} }