6cc2d8af58
* 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>
302 lines
8.4 KiB
TypeScript
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))
|
|
}
|
|
)
|
|
}
|