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:
parent
63de359d22
commit
03f36cf954
|
@ -1,10 +1,14 @@
|
|||
// Currently, comments are created after the bet, not atomically with the bet.
|
||||
// They're uniquely identified by the pair contractId/betId.
|
||||
export type Comment = {
|
||||
id: string
|
||||
contractId: string
|
||||
betId: string
|
||||
userId: string
|
||||
|
||||
text: string
|
||||
createdTime: number
|
||||
|
||||
// Denormalized, for rendering comments
|
||||
userName?: string
|
||||
userUsername?: string
|
||||
|
|
12
common/util/clean-username.ts
Normal file
12
common/util/clean-username.ts
Normal 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()
|
||||
}
|
101
functions/src/change-user-info.ts
Normal file
101
functions/src/change-user-info.ts
Normal 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()
|
|
@ -9,6 +9,10 @@ import {
|
|||
} from '../../common/user'
|
||||
import { getUser, getUserByUsername } from './utils'
|
||||
import { randomString } from '../../common/util/random'
|
||||
import {
|
||||
cleanDisplayName,
|
||||
cleanUsername,
|
||||
} from '../../common/util/clean-username'
|
||||
|
||||
export const createUser = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
|
@ -29,7 +33,8 @@ export const createUser = functions
|
|||
const email = fbUser.email
|
||||
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)
|
||||
|
||||
const sameNameUser = await getUserByUsername(username)
|
||||
|
@ -77,14 +82,6 @@ export const createUser = functions
|
|||
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 isPrivateUserWithDeviceToken = async (deviceToken: string) => {
|
||||
|
|
|
@ -17,3 +17,4 @@ export * from './unsubscribe'
|
|||
export * from './update-contract-metrics'
|
||||
export * from './update-user-metrics'
|
||||
export * from './backup-db'
|
||||
export * from './change-user-info'
|
||||
|
|
50
functions/src/scripts/change-user-info.ts
Normal file
50
functions/src/scripts/change-user-info.ts
Normal 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())
|
|
@ -77,3 +77,13 @@ export const chargeUser = (userId: string, charge: number) => {
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useUser } from '../hooks/use-user'
|
||||
import { checkoutURL } from '../lib/service/stripe'
|
||||
|
@ -13,6 +13,8 @@ export function AddFundsButton(props: { className?: string }) {
|
|||
500 | 1000 | 2500 | 10000
|
||||
>(500)
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<label
|
||||
|
@ -54,11 +56,7 @@ export function AddFundsButton(props: { className?: string }) {
|
|||
</label>
|
||||
|
||||
<form
|
||||
action={checkoutURL(
|
||||
user?.id || '',
|
||||
amountSelected,
|
||||
window.location.href
|
||||
)}
|
||||
action={checkoutURL(user?.id || '', amountSelected, location)}
|
||||
method="POST"
|
||||
>
|
||||
<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
|
||||
}
|
||||
|
|
|
@ -34,6 +34,10 @@ function getNavigationOptions(
|
|||
name: 'Home',
|
||||
href: user ? '/home' : '/',
|
||||
},
|
||||
{
|
||||
name: 'Profile',
|
||||
href: '/profile',
|
||||
},
|
||||
...(mobile
|
||||
? [
|
||||
{
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
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 = () => {
|
||||
const [user, setUser] = useState<User | null | undefined>(undefined)
|
||||
|
@ -14,3 +20,15 @@ export const useUser = () => {
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -33,3 +33,13 @@ export const createUser: () => Promise<User | null> = () => {
|
|||
.then((r) => (r.data as any)?.user || 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 }))
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
where,
|
||||
orderBy,
|
||||
} from 'firebase/firestore'
|
||||
|
||||
import { getValues, listenForValues } from './utils'
|
||||
import { db } from './init'
|
||||
import { User } from '../../../common/user'
|
||||
|
@ -20,15 +21,20 @@ export async function createComment(
|
|||
commenter: User
|
||||
) {
|
||||
const ref = doc(getCommentsCollection(contractId), betId)
|
||||
return await setDoc(ref, {
|
||||
|
||||
const comment: Comment = {
|
||||
id: ref.id,
|
||||
contractId,
|
||||
betId,
|
||||
userId: commenter.id,
|
||||
text,
|
||||
createdTime: Date.now(),
|
||||
userName: commenter.name,
|
||||
userUsername: commenter.username,
|
||||
userAvatarUrl: commenter.avatarUrl,
|
||||
})
|
||||
}
|
||||
|
||||
return await setDoc(ref, comment)
|
||||
}
|
||||
|
||||
function getCommentsCollection(contractId: string) {
|
||||
|
|
49
web/lib/firebase/storage.ts
Normal file
49
web/lib/firebase/storage.ts
Normal 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
|
||||
}
|
|
@ -53,6 +53,16 @@ export function listenForUser(
|
|||
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'
|
||||
|
||||
// used to avoid weird race condition
|
||||
|
|
181
web/pages/profile.tsx
Normal file
181
web/pages/profile.tsx
Normal 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>
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user