manifold/web/lib/firebase/users.ts
Austin Chen 6cc2d8af58
Manalink: Send mana to anyone via link (#114)
* Set up Firestore structure for mana bounty links

* Split up manalinks into successes and failures

* Allow clients to create manalinks

* Track txnId and successful users

* Store custom amounts in the link

* List all manalinks you've created

* Support backend for claiming manalinks

* Add some more error handling

* Tweak readme

* Fix typescript breakage

* Revert "Convert common imports in functions to be absolute"

This reverts commit c03518e906.

* Scaffolding so `claimManalink` works

* Clean up imports

* Barebones endpoint to claim mana

* Fix rules to only allow link creators to query

* Design out claim giftcard

* List all claimed transactions

* Style in a more awesome card

* Fix import

* Padding tweak

* Fix useManalinkTxns hook

* /send -> /link

* Tidy up some details

* Do a bunch of random manalinks work

* Fix up LinksTable to build

* Clean up LinksTable an absurd amount

* Basic details functionality on manalinks table

* Work on manalink claim stuff

* Fix up some merge mess

* Not-signed-in flow implemented

* Better manalinks table

* Only show outstanding links in table

* Use new `ManalinkTxn` type

* /link -> /links

* Change manalinks page UI to use nice looking tabs

* Many fixes to manalinks UI

* Default to 1 use

* Tidying up

* Some copy changes based on feedback

* Add required index

Co-authored-by: Marshall Polaris <marshall@pol.rs>
2022-06-23 01:07:52 -07:00

302 lines
8.4 KiB
TypeScript

import {
getFirestore,
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 { range, throttle, zip } from 'lodash'
import { app } from './init'
import { PrivateUser, User } from 'common/user'
import { createUser } from './fn-call'
import { getValue, getValues, listenForValue, listenForValues } from './utils'
import { DAY_MS } from 'common/util/time'
import { feed } from 'common/feed'
import { CATEGORY_LIST } from 'common/categories'
import { safeLocalStorage } from '../util/local'
import { filterDefined } from 'common/util/array'
export type { User }
export type LeaderboardPeriod = 'daily' | 'weekly' | 'monthly' | 'allTime'
const db = getFirestore(app)
export const auth = getAuth(app)
export const userDocRef = (userId: string) => doc(db, 'users', userId)
export async function getUser(userId: string) {
const docSnap = await getDoc(userDocRef(userId))
return docSnap.data() as User
}
export async function getUserByUsername(username: string) {
// Find a user whose username matches the given username, or null if no such user exists.
const userCollection = collection(db, 'users')
const q = query(userCollection, where('username', '==', username), limit(1))
const docs = await getDocs(q)
const users = docs.docs.map((doc) => doc.data() as User)
return users[0] || null
}
export async function setUser(userId: string, user: User) {
await setDoc(doc(db, 'users', userId), user)
}
export async function updateUser(userId: string, update: Partial<User>) {
await updateDoc(doc(db, 'users', userId), { ...update })
}
export async function updatePrivateUser(
userId: string,
update: Partial<PrivateUser>
) {
await updateDoc(doc(db, 'private-users', userId), { ...update })
}
export function listenForUser(
userId: string,
setUser: (user: User | null) => void
) {
const userRef = doc(db, 'users', userId)
return listenForValue<User>(userRef, setUser)
}
export function listenForPrivateUser(
userId: string,
setPrivateUser: (privateUser: PrivateUser | null) => void
) {
const userRef = doc(db, 'private-users', userId)
return listenForValue<PrivateUser>(userRef, setPrivateUser)
}
const CACHED_USER_KEY = 'CACHED_USER_KEY'
// used to avoid weird race condition
let createUserPromise: Promise<User | null> | 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 userCollection = collection(db, 'users')
const q = query(userCollection, where('id', 'in', userIds))
const docs = await getDocs(q)
return docs.docs.map((doc) => doc.data() as User)
}
export async function listAllUsers() {
const userCollection = collection(db, 'users')
const q = query(userCollection)
const docs = await getDocs(q)
return docs.docs.map((doc) => doc.data() as User)
}
export function listenForAllUsers(setUsers: (users: User[]) => void) {
const userCollection = collection(db, 'users')
const q = query(userCollection)
listenForValues(q, setUsers)
}
export function listenForPrivateUsers(
setUsers: (users: PrivateUser[]) => void
) {
const userCollection = collection(db, 'private-users')
const q = query(userCollection)
listenForValues(q, setUsers)
}
export function getTopTraders(period: LeaderboardPeriod) {
const topTraders = query(
collection(db, 'users'),
orderBy('profitCached.' + period, 'desc'),
limit(20)
)
return getValues(topTraders)
}
export function getTopCreators(period: LeaderboardPeriod) {
const topCreators = query(
collection(db, 'users'),
orderBy('creatorVolumeCached.' + period, 'desc'),
limit(20)
)
return getValues(topCreators)
}
export async function getTopFollowed() {
const users = await getValues<User>(topFollowedQuery)
return users.slice(0, 20)
}
const topFollowedQuery = query(
collection(db, 'users'),
orderBy('followerCountCached', 'desc'),
limit(20)
)
export function getUsers() {
return getValues<User>(collection(db, 'users'))
}
const getUsersQuery = (startTime: number, endTime: number) =>
query(
collection(db, 'users'),
where('createdTime', '>=', startTime),
where('createdTime', '<', endTime),
orderBy('createdTime', 'asc')
)
export async function getDailyNewUsers(
startTime: number,
numberOfDays: number
) {
const query = getUsersQuery(startTime, startTime + DAY_MS * numberOfDays)
const users = await getValues<User>(query)
const usersByDay = range(0, numberOfDays).map(() => [] as User[])
for (const user of users) {
const dayIndex = Math.floor((user.createdTime - startTime) / DAY_MS)
usersByDay[dayIndex].push(user)
}
return usersByDay
}
export async function getUserFeed(userId: string) {
const feedDoc = doc(db, 'private-users', userId, 'cache', 'feed')
const userFeed = await getValue<{
feed: feed
}>(feedDoc)
return userFeed?.feed ?? []
}
export async function getCategoryFeeds(userId: string) {
const cacheCollection = collection(db, 'private-users', 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(db, 'users', userId, 'follows', followedUserId)
await setDoc(followDoc, {
userId: followedUserId,
timestamp: Date.now(),
})
}
export async function unfollow(userId: string, unfollowedUserId: string) {
const followDoc = doc(db, 'users', userId, 'follows', unfollowedUserId)
await deleteDoc(followDoc)
}
export function listenForFollows(
userId: string,
setFollowIds: (followIds: string[]) => void
) {
const follows = collection(db, '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))
}
)
}