User profile (#44)

* add id, userId to comment

* change user info cloud function and script; move cleanUsername to common

* change user info script

* fix rules

* add fund button: useLocation hook

* profile page

* merge

* profile stuff

* avatar uploading to storage bucket

* changeUserInfo: use transaction

* Styles for profile page

* Edit mode for profile, and more styles

Co-authored-by: James Grugett <jahooma@gmail.com>
This commit is contained in:
mantikoros 2022-02-03 21:04:56 -06:00 committed by GitHub
parent 63de359d22
commit 03f36cf954
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 479 additions and 18 deletions

View File

@ -1,10 +1,14 @@
// Currently, comments are created after the bet, not atomically with the bet. // Currently, comments are created after the bet, not atomically with the bet.
// They're uniquely identified by the pair contractId/betId. // They're uniquely identified by the pair contractId/betId.
export type Comment = { export type Comment = {
id: string
contractId: string contractId: string
betId: string betId: string
userId: string
text: string text: string
createdTime: number createdTime: number
// Denormalized, for rendering comments // Denormalized, for rendering comments
userName?: string userName?: string
userUsername?: string userUsername?: string

View File

@ -0,0 +1,12 @@
export const cleanUsername = (name: string, maxLength = 25) => {
return name
.replace(/\s+/g, '')
.normalize('NFD') // split an accented letter in the base letter and the acent
.replace(/[\u0300-\u036f]/g, '') // remove all previously split accents
.replace(/[^A-Za-z0-9_]/g, '') // remove all chars not letters, numbers and underscores
.substring(0, maxLength)
}
export const cleanDisplayName = (displayName: string, maxLength = 25) => {
return displayName.replace(/\s+/g, ' ').substring(0, maxLength).trim()
}

View File

@ -0,0 +1,101 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { getUser, removeUndefinedProps } from './utils'
import { Contract } from '../../common/contract'
import { Comment } from '../../common/comment'
import { User } from '../../common/user'
import { cleanUsername } from '../../common/util/clean-username'
export const changeUserInfo = functions
.runWith({ minInstances: 1 })
.https.onCall(
async (
data: {
username?: string
name?: string
avatarUrl?: string
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const user = await getUser(userId)
if (!user) return { status: 'error', message: 'User not found' }
const { username, name, avatarUrl } = data
return await changeUser(user, { username, name, avatarUrl })
.then(() => {
console.log('succesfully changed', user.username, 'to', data)
return { status: 'success' }
})
.catch((e) => {
console.log('Error', e.message)
return { status: 'error', message: e.message }
})
}
)
export const changeUser = async (
user: User,
update: {
username?: string
name?: string
avatarUrl?: string
}
) => {
return await firestore.runTransaction(async (transaction) => {
if (update.username) {
update.username = cleanUsername(update.username)
if (!update.username) {
throw new Error('Invalid username')
}
const sameNameUser = await transaction.get(
firestore.collection('users').where('username', '==', update.username)
)
if (!sameNameUser.empty) {
throw new Error('Username already exists')
}
}
const userRef = firestore.collection('users').doc(user.id)
const userUpdate: Partial<User> = removeUndefinedProps(update)
const contractsRef = firestore
.collection('contracts')
.where('creatorId', '==', user.id)
const contracts = await transaction.get(contractsRef)
const contractUpdate: Partial<Contract> = removeUndefinedProps({
creatorName: update.name,
creatorUsername: update.username,
creatorAvatarUrl: update.avatarUrl,
})
const commentSnap = await transaction.get(
firestore
.collectionGroup('comments')
.where('userUsername', '==', user.username)
)
const commentUpdate: Partial<Comment> = removeUndefinedProps({
userName: update.name,
userUsername: update.username,
userAvatarUrl: update.avatarUrl,
})
await transaction.update(userRef, userUpdate)
await Promise.all(
commentSnap.docs.map((d) => transaction.update(d.ref, commentUpdate))
)
await contracts.docs.map((d) => transaction.update(d.ref, contractUpdate))
})
}
const firestore = admin.firestore()

View File

@ -9,6 +9,10 @@ import {
} from '../../common/user' } from '../../common/user'
import { getUser, getUserByUsername } from './utils' import { getUser, getUserByUsername } from './utils'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import {
cleanDisplayName,
cleanUsername,
} from '../../common/util/clean-username'
export const createUser = functions export const createUser = functions
.runWith({ minInstances: 1 }) .runWith({ minInstances: 1 })
@ -29,7 +33,8 @@ export const createUser = functions
const email = fbUser.email const email = fbUser.email
const emailName = email?.replace(/@.*$/, '') const emailName = email?.replace(/@.*$/, '')
const name = fbUser.displayName || emailName || 'User' + randomString(4) const rawName = fbUser.displayName || emailName || 'User' + randomString(4)
const name = cleanDisplayName(rawName)
let username = cleanUsername(name) let username = cleanUsername(name)
const sameNameUser = await getUserByUsername(username) const sameNameUser = await getUserByUsername(username)
@ -77,14 +82,6 @@ export const createUser = functions
return { status: 'success', user } return { status: 'success', user }
}) })
const cleanUsername = (name: string) => {
return name
.replace(/\s+/g, '')
.normalize('NFD') // split an accented letter in the base letter and the acent
.replace(/[\u0300-\u036f]/g, '') // remove all previously split accents
.replace(/[^A-Za-z0-9_]/g, '') // remove all chars not letters, numbers and underscores
}
const firestore = admin.firestore() const firestore = admin.firestore()
const isPrivateUserWithDeviceToken = async (deviceToken: string) => { const isPrivateUserWithDeviceToken = async (deviceToken: string) => {

View File

@ -17,3 +17,4 @@ export * from './unsubscribe'
export * from './update-contract-metrics' export * from './update-contract-metrics'
export * from './update-user-metrics' export * from './update-user-metrics'
export * from './backup-db' export * from './backup-db'
export * from './change-user-info'

View File

@ -0,0 +1,50 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
// Generate your own private key, and set the path below:
// https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk
// const serviceAccount = require('../../../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json')
const serviceAccount = require('../../../../../../Downloads/mantic-markets-firebase-adminsdk-1ep46-351a65eca3.json')
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
})
import { getUserByUsername } from '../utils'
import { changeUser } from '../change-user-info'
async function main() {
const username = process.argv[2]
const name = process.argv[3]
const newUsername = process.argv[4]
const avatarUrl = process.argv[5]
if (process.argv.length < 4) {
console.log(
'syntax: node change-user-info.js [current username] [new name] [new username] [new avatar]'
)
return
}
const user = await getUserByUsername(username)
if (!user) {
console.log('username', username, 'could not be found')
return
}
await changeUser(user, { username: newUsername, name, avatarUrl })
.then(() =>
console.log(
'succesfully changed',
user.username,
'to',
name,
avatarUrl,
newUsername
)
)
.catch((e) => console.log(e.message))
}
if (require.main === module) main().then(() => process.exit())

View File

@ -77,3 +77,13 @@ export const chargeUser = (userId: string, charge: number) => {
return updateUserBalance(userId, -charge) return updateUserBalance(userId, -charge)
} }
export const removeUndefinedProps = <T>(obj: T): T => {
let newObj: any = {}
for (let key of Object.keys(obj)) {
if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key]
}
return newObj
}

View File

@ -1,5 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useUser } from '../hooks/use-user' import { useUser } from '../hooks/use-user'
import { checkoutURL } from '../lib/service/stripe' import { checkoutURL } from '../lib/service/stripe'
@ -13,6 +13,8 @@ export function AddFundsButton(props: { className?: string }) {
500 | 1000 | 2500 | 10000 500 | 1000 | 2500 | 10000
>(500) >(500)
const location = useLocation()
return ( return (
<> <>
<label <label
@ -54,11 +56,7 @@ export function AddFundsButton(props: { className?: string }) {
</label> </label>
<form <form
action={checkoutURL( action={checkoutURL(user?.id || '', amountSelected, location)}
user?.id || '',
amountSelected,
window.location.href
)}
method="POST" method="POST"
> >
<button <button
@ -74,3 +72,13 @@ export function AddFundsButton(props: { className?: string }) {
</> </>
) )
} }
// needed in next js
// window not loaded at runtime
const useLocation = () => {
const [href, setHref] = useState('')
useEffect(() => {
setHref(window.location.href)
}, [])
return href
}

View File

@ -34,6 +34,10 @@ function getNavigationOptions(
name: 'Home', name: 'Home',
href: user ? '/home' : '/', href: user ? '/home' : '/',
}, },
{
name: 'Profile',
href: '/profile',
},
...(mobile ...(mobile
? [ ? [
{ {

View File

@ -1,5 +1,11 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { listenForLogin, listenForUser, User } from '../lib/firebase/users' import { PrivateUser } from '../../common/user'
import {
listenForLogin,
listenForPrivateUser,
listenForUser,
User,
} from '../lib/firebase/users'
export const useUser = () => { export const useUser = () => {
const [user, setUser] = useState<User | null | undefined>(undefined) const [user, setUser] = useState<User | null | undefined>(undefined)
@ -14,3 +20,15 @@ export const useUser = () => {
return user return user
} }
export const usePrivateUser = (userId?: string) => {
const [privateUser, setPrivateUser] = useState<
PrivateUser | null | undefined
>(undefined)
useEffect(() => {
if (userId) return listenForPrivateUser(userId, setPrivateUser)
}, [userId])
return privateUser
}

View File

@ -33,3 +33,13 @@ export const createUser: () => Promise<User | null> = () => {
.then((r) => (r.data as any)?.user || null) .then((r) => (r.data as any)?.user || null)
.catch(() => null) .catch(() => null)
} }
export const changeUserInfo = (data: {
username?: string
name?: string
avatarUrl?: string
}) => {
return cloudFunction('changeUserInfo')(data)
.then((r) => r.data as { status: string; message?: string })
.catch((e) => ({ status: 'error', message: e.message }))
}

View File

@ -7,6 +7,7 @@ import {
where, where,
orderBy, orderBy,
} from 'firebase/firestore' } from 'firebase/firestore'
import { getValues, listenForValues } from './utils' import { getValues, listenForValues } from './utils'
import { db } from './init' import { db } from './init'
import { User } from '../../../common/user' import { User } from '../../../common/user'
@ -20,15 +21,20 @@ export async function createComment(
commenter: User commenter: User
) { ) {
const ref = doc(getCommentsCollection(contractId), betId) const ref = doc(getCommentsCollection(contractId), betId)
return await setDoc(ref, {
const comment: Comment = {
id: ref.id,
contractId, contractId,
betId, betId,
userId: commenter.id,
text, text,
createdTime: Date.now(), createdTime: Date.now(),
userName: commenter.name, userName: commenter.name,
userUsername: commenter.username, userUsername: commenter.username,
userAvatarUrl: commenter.avatarUrl, userAvatarUrl: commenter.avatarUrl,
}) }
return await setDoc(ref, comment)
} }
function getCommentsCollection(contractId: string) { function getCommentsCollection(contractId: string) {

View File

@ -0,0 +1,49 @@
import {
getStorage,
ref,
uploadBytesResumable,
getDownloadURL,
} from 'firebase/storage'
const storage = getStorage()
export const uploadImage = async (
username: string,
file: File,
onProgress?: (progress: number, isRunning: boolean) => void
) => {
const storageRef = ref(storage, `user-images/${username}/${file.name}`)
const uploadTask = uploadBytesResumable(storageRef, file)
let resolvePromise: (url: string) => void
let rejectPromise: (reason?: any) => void
const promise = new Promise<string>((resolve, reject) => {
resolvePromise = resolve
rejectPromise = reject
})
const unsubscribe = uploadTask.on(
'state_changed',
(snapshot) => {
const progress = snapshot.bytesTransferred / snapshot.totalBytes
const isRunning = snapshot.state === 'running'
if (onProgress) onProgress(progress, isRunning)
},
(error) => {
// A full list of error codes is available at
// https://firebase.google.com/docs/storage/web/handle-errors
rejectPromise(error)
unsubscribe()
},
() => {
getDownloadURL(uploadTask.snapshot.ref).then((downloadURL) => {
resolvePromise(downloadURL)
})
unsubscribe()
}
)
return await promise
}

View File

@ -53,6 +53,16 @@ export function listenForUser(
return listenForValue<User>(userRef, setUser) return listenForValue<User>(userRef, setUser)
} }
export function listenForPrivateUser(
userId: string,
setPrivateUser: (privateUser: PrivateUser) => void
) {
const userRef = doc(db, 'private-users', userId)
return onSnapshot(userRef, (userSnap) => {
setPrivateUser(userSnap.data() as PrivateUser)
})
}
const CACHED_USER_KEY = 'CACHED_USER_KEY' const CACHED_USER_KEY = 'CACHED_USER_KEY'
// used to avoid weird race condition // used to avoid weird race condition

181
web/pages/profile.tsx Normal file
View File

@ -0,0 +1,181 @@
import { useEffect, useState } from 'react'
import { PencilIcon } from '@heroicons/react/outline'
import { AddFundsButton } from '../components/add-funds-button'
import { Page } from '../components/page'
import { SEO } from '../components/SEO'
import { Title } from '../components/title'
import { usePrivateUser, useUser } from '../hooks/use-user'
import { formatMoney } from '../../common/util/format'
import {
cleanDisplayName,
cleanUsername,
} from '../../common/util/clean-username'
import { changeUserInfo } from '../lib/firebase/api-call'
import { uploadImage } from '../lib/firebase/storage'
import { Col } from '../components/layout/col'
import { Row } from '../components/layout/row'
export default function ProfilePage() {
const user = useUser()
const privateUser = usePrivateUser(user?.id)
const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl || '')
const [avatarLoading, setAvatarLoading] = useState(false)
const [name, setName] = useState(user?.name || '')
const [username, setUsername] = useState(user?.username || '')
const [isEditing, setIsEditing] = useState(false)
useEffect(() => {
if (user) {
setAvatarUrl(user.avatarUrl || '')
setName(user.name || '')
setUsername(user.username || '')
}
}, [user])
const updateDisplayName = async () => {
const newName = cleanDisplayName(name)
if (newName) {
setName(newName)
await changeUserInfo({ name: newName })
.catch(() => ({ status: 'error' }))
.then((r) => {
if (r.status === 'error') setName(user?.name || '')
})
} else {
setName(user?.name || '')
}
}
const updateUsername = async () => {
const newUsername = cleanUsername(username)
if (newUsername) {
setUsername(newUsername)
await changeUserInfo({ username: newUsername })
.catch(() => ({ status: 'error' }))
.then((r) => {
if (r.status === 'error') setUsername(user?.username || '')
})
} else {
setUsername(user?.username || '')
}
}
const fileHandler = async (event: any) => {
const file = event.target.files[0]
setAvatarLoading(true)
await uploadImage(user?.username || 'default', file)
.then(async (url) => {
await changeUserInfo({ avatarUrl: url })
setAvatarUrl(url)
setAvatarLoading(false)
})
.catch(() => {
setAvatarLoading(false)
setAvatarUrl(user?.avatarUrl || '')
})
}
return (
<Page>
<SEO title="Profile" description="User profile settings" url="/profile" />
<Col className="max-w-lg p-6 sm:mx-auto bg-white rounded shadow-md">
<Row className="justify-between">
<Title className="!mt-0" text="Profile" />
{isEditing ? (
<button
className="btn btn-primary"
onClick={() => setIsEditing(false)}
>
Done
</button>
) : (
<button
className="btn btn-ghost"
onClick={() => setIsEditing(true)}
>
<PencilIcon className="w-5 h-5" />{' '}
<div className="ml-2">Edit</div>
</button>
)}
</Row>
<Col className="gap-4">
<Row className="items-center gap-4">
{avatarLoading ? (
<button className="btn btn-ghost btn-lg btn-circle loading"></button>
) : (
<>
<img
src={avatarUrl}
width={80}
height={80}
className="rounded-full bg-gray-400 flex items-center justify-center"
/>
{isEditing && (
<input type="file" name="file" onChange={fileHandler} />
)}
</>
)}
</Row>
<div>
<label className="label">Display name</label>
{isEditing ? (
<input
type="text"
placeholder="Display name"
className="input input-bordered"
value={name}
onChange={(e) => setName(e.target.value || '')}
onBlur={updateDisplayName}
/>
) : (
<div className="ml-1 text-gray-500">{name}</div>
)}
</div>
<div>
<label className="label">Username</label>
{isEditing ? (
<input
type="text"
placeholder="Username"
className="input input-bordered"
value={username}
onChange={(e) => setUsername(e.target.value || '')}
onBlur={updateUsername}
/>
) : (
<div className="ml-1 text-gray-500">{username}</div>
)}
</div>
<div>
<label className="label">Email</label>
<div className="ml-1 text-gray-500">
{privateUser?.email ?? '\u00a0'}
</div>
</div>
<div>
<label className="label">Balance</label>
<Row className="ml-1 gap-4 items-start text-gray-500">
{formatMoney(user?.balance || 0)}
<AddFundsButton />
</Row>
</div>
</Col>
</Col>
</Page>
)
}