import { doc, setDoc, getDoc, collection, query, where, limit, getDocs, orderBy, updateDoc, deleteDoc, collectionGroup, onSnapshot, } from 'firebase/firestore' import { getAuth } from 'firebase/auth' import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage' import { onAuthStateChanged, GoogleAuthProvider, signInWithPopup, } from 'firebase/auth' import { throttle, zip } from 'lodash' import { app, db } from './init' import { PortfolioMetrics, PrivateUser, User } from 'common/user' import { createUser } from './fn-call' import { coll, getValue, getValues, listenForValue, listenForValues, } from './utils' import { feed } from 'common/feed' import { CATEGORY_LIST } from 'common/categories' import { safeLocalStorage } from '../util/local' import { filterDefined } from 'common/util/array' export const users = coll('users') export const privateUsers = coll('private-users') export type { User } export type Period = 'daily' | 'weekly' | 'monthly' | 'allTime' export const auth = getAuth(app) export async function getUser(userId: string) { /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ return (await getDoc(doc(users, userId))).data()! } export async function getUserByUsername(username: string) { // Find a user whose username matches the given username, or null if no such user exists. const q = query(users, where('username', '==', username), limit(1)) const docs = (await getDocs(q)).docs return docs.length > 0 ? docs[0].data() : null } export async function setUser(userId: string, user: User) { await setDoc(doc(users, userId), user) } export async function updateUser(userId: string, update: Partial) { await updateDoc(doc(users, userId), { ...update }) } export async function updatePrivateUser( userId: string, update: Partial ) { await updateDoc(doc(privateUsers, userId), { ...update }) } export function listenForUser( userId: string, setUser: (user: User | null) => void ) { const userRef = doc(users, userId) return listenForValue(userRef, setUser) } export function listenForPrivateUser( userId: string, setPrivateUser: (privateUser: PrivateUser | null) => void ) { const userRef = doc(privateUsers, userId) return listenForValue(userRef, setPrivateUser) } const CACHED_USER_KEY = 'CACHED_USER_KEY' // used to avoid weird race condition let createUserPromise: Promise | undefined = undefined const warmUpCreateUser = throttle(createUser, 5000 /* ms */) export function listenForLogin(onUser: (user: User | null) => void) { const local = safeLocalStorage() const cachedUser = local?.getItem(CACHED_USER_KEY) onUser(cachedUser && JSON.parse(cachedUser)) if (!cachedUser) warmUpCreateUser() return onAuthStateChanged(auth, async (fbUser) => { if (fbUser) { let user: User | null = await getUser(fbUser.uid) if (!user) { if (!createUserPromise) { createUserPromise = createUser() } user = (await createUserPromise) || null } 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)) } else { // User logged out; reset to null onUser(null) local?.removeItem(CACHED_USER_KEY) createUserPromise = undefined } }) } export async function firebaseLogin() { const provider = new GoogleAuthProvider() return signInWithPopup(auth, provider) } export async function firebaseLogout() { auth.signOut() } const storage = getStorage(app) // Example: uploadData('avatars/ajfi8iejsf.png', data) export async function uploadData( path: string, data: ArrayBuffer | Blob | Uint8Array ) { const uploadRef = ref(storage, path) // Uploaded files should be cached for 1 day, then revalidated // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control const metadata = { cacheControl: 'public, max-age=86400, must-revalidate' } await uploadBytes(uploadRef, data, metadata) return await getDownloadURL(uploadRef) } export async function listUsers(userIds: string[]) { if (userIds.length > 10) { throw new Error('Too many users requested at once; Firestore limits to 10') } const q = query(users, where('id', 'in', userIds)) const docs = (await getDocs(q)).docs return docs.map((doc) => doc.data()) } export async function listAllUsers() { const docs = (await getDocs(users)).docs return docs.map((doc) => doc.data()) } export function listenForAllUsers(setUsers: (users: User[]) => void) { listenForValues(users, setUsers) } export function listenForPrivateUsers( setUsers: (users: PrivateUser[]) => void ) { listenForValues(privateUsers, setUsers) } export function getTopTraders(period: Period) { const topTraders = query( users, orderBy('profitCached.' + period, 'desc'), limit(20) ) return getValues(topTraders) } export function getTopCreators(period: Period) { const topCreators = query( users, orderBy('creatorVolumeCached.' + period, 'desc'), limit(20) ) return getValues(topCreators) } export async function getTopFollowed() { return (await getValues(topFollowedQuery)).slice(0, 20) } const topFollowedQuery = query( users, orderBy('followerCountCached', 'desc'), limit(20) ) export function getUsers() { return getValues(users) } export async function getUserFeed(userId: string) { const feedDoc = doc(privateUsers, userId, 'cache', 'feed') const userFeed = await getValue<{ feed: feed }>(feedDoc) return userFeed?.feed ?? [] } export async function getCategoryFeeds(userId: string) { const cacheCollection = collection(privateUsers, userId, 'cache') const feedData = await Promise.all( CATEGORY_LIST.map((category) => getValue<{ feed: feed }>(doc(cacheCollection, `feed-${category}`)) ) ) const feeds = feedData.map((data) => data?.feed ?? []) return Object.fromEntries(zip(CATEGORY_LIST, feeds) as [string, feed][]) } export async function follow(userId: string, followedUserId: string) { const followDoc = doc(collection(users, userId, 'follows'), followedUserId) await setDoc(followDoc, { userId: followedUserId, timestamp: Date.now(), }) } export async function unfollow(userId: string, unfollowedUserId: string) { const followDoc = doc(collection(users, userId, 'follows'), unfollowedUserId) await deleteDoc(followDoc) } export async function getPortfolioHistory(userId: string) { return getValues( query( collectionGroup(db, 'portfolioHistory'), where('userId', '==', userId), orderBy('timestamp', 'asc') ) ) } export function listenForFollows( userId: string, setFollowIds: (followIds: string[]) => void ) { const follows = collection(users, userId, 'follows') return listenForValues<{ userId: string }>(follows, (docs) => setFollowIds(docs.map(({ userId }) => userId)) ) } export function listenForFollowers( userId: string, setFollowerIds: (followerIds: string[]) => void ) { const followersQuery = query( collectionGroup(db, 'follows'), where('userId', '==', userId) ) return onSnapshot( followersQuery, { includeMetadataChanges: true }, (snapshot) => { if (snapshot.metadata.fromCache) return const values = snapshot.docs.map((doc) => doc.ref.parent.parent?.id) setFollowerIds(filterDefined(values)) } ) }