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()
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 <LoadingIndicator />

View File

@ -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 (
<div className={'flex gap-2'}>
@ -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}

View File

@ -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())
})

View File

@ -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'

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 { 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<User | null | undefined>(
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) => {

View File

@ -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,

View File

@ -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<User>('users')
export const privateUsers = coll<PrivateUser>('private-users')
@ -97,7 +90,6 @@ export function listenForPrivateUser(
return listenForValue<PrivateUser>(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<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() {
const provider = new GoogleAuthProvider()
return signInWithPopup(auth, provider)

View File

@ -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) {
/>
</Head>
<AuthProvider>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
</AuthProvider>
</>
)
}