diff --git a/mani/app/_layout.tsx b/mani/app/_layout.tsx
index 3568fb3b94..d7622043dd 100644
--- a/mani/app/_layout.tsx
+++ b/mani/app/_layout.tsx
@@ -145,6 +145,8 @@ function RootLayout() {
+
+
)}
diff --git a/mani/app/account-settings.tsx b/mani/app/account-settings.tsx
new file mode 100644
index 0000000000..09da88689e
--- /dev/null
+++ b/mani/app/account-settings.tsx
@@ -0,0 +1,165 @@
+import { useState } from 'react'
+import { Alert } from 'react-native'
+import { Col } from 'components/layout/col'
+import { Row } from 'components/layout/row'
+import { ThemedText } from 'components/themed-text'
+import { Button } from 'components/buttons/button'
+import { useColor } from 'hooks/use-color'
+import { usePrivateUser, useUser } from 'hooks/use-user'
+import { api } from 'lib/api'
+import { Input } from 'components/widgets/input'
+import Page from 'components/page'
+import { Switch } from 'components/form/switch'
+import { IconSymbol } from 'components/ui/icon-symbol'
+import { capitalize } from 'lodash'
+import { TRADE_TERM } from 'common/envs/constants'
+import { auth } from 'lib/firebase/init'
+import { Toast } from 'react-native-toast-message/lib/src/Toast'
+import Clipboard from 'expo-clipboard'
+
+function SettingRow(props: { label: string; children: React.ReactNode }) {
+ const { label, children } = props
+ return (
+
+
+ {label}
+
+ {children}
+
+ )
+}
+
+export default function AccountSettingsPage() {
+ const user = useUser()
+ const privateUser = usePrivateUser()
+ const color = useColor()
+ const [apiKey, setApiKey] = useState(privateUser?.apiKey)
+ const [betWarnings, setBetWarnings] = useState(!user?.optOutBetWarnings)
+ const [loading, setLoading] = useState(false)
+
+ const updateApiKey = async () => {
+ setLoading(true)
+ const newApiKey = await generateNewApiKey()
+ setApiKey(newApiKey ?? '')
+ setLoading(false)
+ }
+
+ const copyApiKey = async () => {
+ if (!apiKey) return
+ await Clipboard.setStringAsync(apiKey)
+ Toast.show({
+ type: 'success',
+ text1: 'API key copied to clipboard',
+ })
+ }
+
+ const confirmApiKeyUpdate = () => {
+ Alert.alert(
+ 'Update API Key',
+ 'Updating your API key will break any existing applications connected to your account. Are you sure?',
+ [
+ {
+ text: 'Cancel',
+ style: 'cancel',
+ },
+ {
+ text: 'Update',
+ onPress: updateApiKey,
+ },
+ ]
+ )
+ }
+ if (!user) return null
+
+ const deleteAccount = async () => {
+ await api('me/delete', { username: user.username })
+ await auth.signOut()
+ }
+
+ const confirmDeleteAccount = () => {
+ Alert.alert(
+ 'Delete Account',
+ 'Are you sure you want to delete your account? This action cannot be undone.',
+ [
+ {
+ text: 'Cancel',
+ style: 'cancel',
+ },
+ {
+ text: 'Delete',
+ onPress: deleteAccount,
+ style: 'destructive',
+ },
+ ]
+ )
+ }
+
+ return (
+
+
+
+ {
+ setBetWarnings(enabled)
+ api('me/update', { optOutBetWarnings: !enabled })
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const generateNewApiKey = async () => {
+ const newApiKey = crypto.randomUUID()
+
+ try {
+ await api('me/private/update', { apiKey: newApiKey })
+ } catch (e) {
+ console.error(e)
+ return undefined
+ }
+ return newApiKey
+}
diff --git a/mani/app/edit-profile.tsx b/mani/app/edit-profile.tsx
new file mode 100644
index 0000000000..e2265cc14c
--- /dev/null
+++ b/mani/app/edit-profile.tsx
@@ -0,0 +1,253 @@
+import { useState } from 'react'
+import { View, Image, TouchableOpacity } from 'react-native'
+import { Col } from 'components/layout/col'
+import { Row } from 'components/layout/row'
+import { ThemedText } from 'components/themed-text'
+import { Button } from 'components/buttons/button'
+import { useColor } from 'hooks/use-color'
+import { usePrivateUser, useUser } from 'hooks/use-user'
+import { api } from 'lib/api'
+import * as ImagePicker from 'expo-image-picker'
+import { Input } from 'components/widgets/input'
+import { Rounded } from 'constants/border-radius'
+import Page from 'components/page'
+import { useEditableUserInfo } from 'hooks/use-editable-user-info'
+import { uploadPublicImage } from 'lib/firebase/storage'
+import { nanoid } from 'common/util/random'
+import { APIError } from 'common/api/utils'
+
+function EditUserField(props: {
+ label: string
+ field: string
+ value: string
+ setValue: (value: string) => void
+ multiline?: boolean
+}) {
+ const { label, value, setValue, multiline } = props
+
+ return (
+
+
+ {label}
+
+
+
+ )
+}
+
+export default function EditProfilePage() {
+ const user = useUser()
+ const color = useColor()
+ const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl || '')
+ const [loading, setLoading] = useState(false)
+
+ const { userInfo, updateDisplayName, updateUsername, updateUserState } =
+ useEditableUserInfo(user)
+ const privateUser = usePrivateUser()
+ const {
+ name,
+ username,
+ errorUsername,
+ loadingUsername,
+ loadingName,
+ errorName,
+ } = userInfo
+
+ const [bio, setBio] = useState(user?.bio || '')
+ const [website, setWebsite] = useState(user?.website || '')
+ const [twitterHandle, setTwitterHandle] = useState(user?.twitterHandle || '')
+ const [discordHandle, setDiscordHandle] = useState(user?.discordHandle || '')
+ const [finishedUpdating, setFinishedUpdating] = useState(false)
+ const [error, setError] = useState(null)
+
+ const updates: { [key: string]: string } = {}
+
+ if (bio.trim() !== (user?.bio || '').trim()) updates.bio = bio.trim()
+ if (website.trim() !== (user?.website || '').trim())
+ updates.website = website.trim()
+ if (twitterHandle.trim() !== (user?.twitterHandle || '').trim())
+ updates.twitterHandle = twitterHandle.trim()
+ if (discordHandle.trim() !== (user?.discordHandle || '').trim())
+ updates.discordHandle = discordHandle.trim()
+ const nameUpdate = name.trim() !== (user?.name || '').trim()
+ const usernameUpdate = username.trim() !== (user?.username || '').trim()
+ const hasUpdates =
+ Object.keys(updates).length > 0 || nameUpdate || usernameUpdate
+
+ const handleSave = async () => {
+ if (!hasUpdates) return
+
+ // Handle name and username updates separately
+ if (nameUpdate) {
+ await updateDisplayName()
+ }
+ if (usernameUpdate) {
+ await updateUsername()
+ }
+
+ setFinishedUpdating(false)
+ setLoading(true)
+ try {
+ await api('me/update', updates)
+ setFinishedUpdating(true)
+ } catch (e) {
+ if (e instanceof APIError) {
+ setError(e.message)
+ } else {
+ setError('An error occurred')
+ }
+ }
+ setLoading(false)
+ }
+
+ const pickImage = async () => {
+ const result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: ImagePicker.MediaTypeOptions.Images,
+ allowsEditing: true,
+ aspect: [1, 1],
+ quality: 1,
+ })
+
+ if (!result.canceled && result.assets[0].uri && user) {
+ setLoading(true)
+ try {
+ const uri = result.assets[0].uri
+ const ext = uri.split('.').pop() || 'jpg'
+ const fileName = `avatar-${nanoid(10)}.${ext}`
+ const url = await uploadPublicImage(user.username, uri, fileName)
+ await api('me/update', { avatarUrl: url })
+ setAvatarUrl(url)
+ } catch (error) {
+ console.error('Error uploading image:', error)
+ }
+ setLoading(false)
+ }
+ }
+
+ if (!user) return null
+
+ return (
+
+
+
+
+
+ {avatarUrl ? (
+
+ ) : (
+ Add Photo
+ )}
+
+
+
+
+
+
+ Display name
+
+ updateUserState({ name: text })}
+ placeholder="Display name"
+ editable={!loadingName}
+ />
+ {errorName && (
+
+ {errorName}
+
+ )}
+
+
+
+
+ Username
+
+ updateUserState({ username: text })}
+ placeholder="Username"
+ editable={!loadingUsername}
+ />
+ {errorUsername && (
+
+ {errorUsername}
+
+ )}
+
+
+
+
+
+
+
+
+
+ Email
+
+
+ {privateUser?.email}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {finishedUpdating && (
+
+ Profile updated!
+
+ )}
+
+
+ )
+}
diff --git a/mani/components/buttons/button.tsx b/mani/components/buttons/button.tsx
index 2c8ee781d7..6c5270dce4 100644
--- a/mani/components/buttons/button.tsx
+++ b/mani/components/buttons/button.tsx
@@ -25,7 +25,8 @@ type ButtonVariant =
| 'no'
| 'danger'
| 'purple'
- | 'emerald' // add more variants as needed
+ | 'emerald'
+ | 'gray-white'
export interface ButtonProps extends TouchableOpacityProps {
title?: string
@@ -130,6 +131,11 @@ export function Button({
background: emerald[600],
text: 'white',
}
+ case 'gray-white':
+ return {
+ background: 'transparent',
+ text: color.text,
+ }
case 'primary':
default:
return {
diff --git a/mani/components/form/switch.tsx b/mani/components/form/switch.tsx
new file mode 100644
index 0000000000..35db02b495
--- /dev/null
+++ b/mani/components/form/switch.tsx
@@ -0,0 +1,14 @@
+import { Switch as RNSwitch, SwitchProps } from 'react-native'
+import { useColor } from 'hooks/use-color'
+
+export function Switch(props: SwitchProps) {
+ const color = useColor()
+ return (
+
+ )
+}
diff --git a/mani/components/profile/profile-content.tsx b/mani/components/profile/profile-content.tsx
index e86a5a8de6..c72f3880ed 100644
--- a/mani/components/profile/profile-content.tsx
+++ b/mani/components/profile/profile-content.tsx
@@ -8,8 +8,6 @@ import { TokenNumber } from 'components/token/token-number'
import { Rounded } from 'constants/border-radius'
import { useColor } from 'hooks/use-color'
import { Image, View } from 'react-native'
-import { auth } from 'lib/firebase/init'
-import { clearData } from 'lib/auth-storage'
import { Button } from 'components/buttons/button'
import { router } from 'expo-router'
import { BalanceChangeTable } from 'components/portfolio/balance-change-table'
@@ -21,6 +19,9 @@ import { useTokenMode } from 'hooks/use-token-mode'
import { KYC_VERIFICATION_BONUS_CASH } from 'common/economy'
import { SWEEPIES_NAME } from 'common/envs/constants'
import { formatMoney } from 'common/util/format'
+import { SettingsModal } from './settings-modal'
+import { useState } from 'react'
+import { IconSymbol } from 'components/ui/icon-symbol'
export function ProfileContent(props: { user: User }) {
const color = useColor()
@@ -28,15 +29,7 @@ export function ProfileContent(props: { user: User }) {
const currentUser = useUser()
const isCurrentUser = currentUser?.id === user.id
const { data: redeemable } = useAPIGetter('get-redeemable-prize-cash', {})
-
- const signOut = async () => {
- try {
- await auth.signOut()
- await clearData('user')
- } catch (err) {
- console.error('Error signing out:', err)
- }
- }
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false)
const { data } = useAPIGetter('get-daily-changed-metrics-and-contracts', {
userId: user.id,
@@ -51,51 +44,53 @@ export function ProfileContent(props: { user: User }) {
const manaNetWorth = manaInvestmentValue + (user?.balance ?? 0)
const cashNetWorth = cashInvestmentValue + (user?.cashBalance ?? 0)
const isUserRegistered = user.idVerified
-
return (
-
-
-
+
+
-
-
-
- {user.name}
-
-
- @{user.username}
-
-
+ >
+
+
+
+
+ {user.name}
+
+
+ @{user.username}
+
+
+
{isCurrentUser && (
+ onPress={() => setIsSettingsOpen(true)}
+ variant="gray-white"
+ size="xs"
+ style={{ justifyContent: 'center' }}
+ >
+
+
)}
@@ -151,6 +146,12 @@ export function ProfileContent(props: { user: User }) {
)}
/>
+ {isCurrentUser && (
+ setIsSettingsOpen(false)}
+ />
+ )}
)
}
diff --git a/mani/components/profile/settings-modal.tsx b/mani/components/profile/settings-modal.tsx
new file mode 100644
index 0000000000..1f0d66ecbf
--- /dev/null
+++ b/mani/components/profile/settings-modal.tsx
@@ -0,0 +1,53 @@
+import { View } from 'react-native'
+import { Modal } from 'components/layout/modal'
+import { Col } from 'components/layout/col'
+import { Button } from 'components/buttons/button'
+import { useColor } from 'hooks/use-color'
+import { auth } from 'lib/firebase/init'
+import { clearData } from 'lib/auth-storage'
+import { router } from 'expo-router'
+
+export function SettingsModal(props: { isOpen: boolean; onClose: () => void }) {
+ const { isOpen, onClose } = props
+ const color = useColor()
+
+ const signOut = async () => {
+ try {
+ await auth.signOut()
+ await clearData('user')
+ onClose()
+ } catch (err) {
+ console.error('Error signing out:', err)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/mani/hooks/use-editable-user-info.ts b/mani/hooks/use-editable-user-info.ts
new file mode 100644
index 0000000000..fafeaee62a
--- /dev/null
+++ b/mani/hooks/use-editable-user-info.ts
@@ -0,0 +1,79 @@
+import { useState } from 'react'
+import { User } from 'common/user'
+import { api } from 'lib/api'
+import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
+
+type UserInfoState = {
+ name: string
+ username: string
+ loadingName: boolean
+ loadingUsername: boolean
+ errorName: string
+ errorUsername: string
+}
+
+export function useEditableUserInfo(user: User | null | undefined) {
+ const [userInfo, setUserInfo] = useState({
+ name: user?.name ?? '',
+ username: user?.username ?? '',
+ loadingName: false,
+ loadingUsername: false,
+ errorName: '',
+ errorUsername: '',
+ })
+
+ const updateUserState = (newState: Partial) => {
+ setUserInfo((prevState) => ({ ...prevState, ...newState }))
+ }
+
+ const updateDisplayName = async () => {
+ if (!user) return
+ const newName = cleanDisplayName(userInfo.name)
+ if (newName === user.name) return
+
+ updateUserState({ loadingName: true, errorName: '' })
+ if (!newName) {
+ updateUserState({ name: user.name })
+ return
+ }
+
+ try {
+ await api('me/update', { name: newName })
+ updateUserState({ errorName: '', name: newName })
+ } catch (error: any) {
+ updateUserState({
+ errorName: error.message || 'Error updating name',
+ name: user.name,
+ })
+ }
+
+ updateUserState({ loadingName: false })
+ }
+
+ const updateUsername = async () => {
+ if (!user) return
+ const newUsername = cleanUsername(userInfo.username)
+ if (newUsername === user.username) return
+
+ updateUserState({ loadingUsername: true, errorUsername: '' })
+
+ try {
+ await api('me/update', { username: newUsername })
+ updateUserState({ errorUsername: '', username: newUsername })
+ } catch (error: any) {
+ updateUserState({
+ errorUsername: error.message || 'Error updating username',
+ username: user.username,
+ })
+ }
+
+ updateUserState({ loadingUsername: false })
+ }
+
+ return {
+ userInfo,
+ updateDisplayName,
+ updateUsername,
+ updateUserState,
+ }
+}
diff --git a/mani/lib/firebase/storage.ts b/mani/lib/firebase/storage.ts
index 02bd2605c8..feb4f61f14 100644
--- a/mani/lib/firebase/storage.ts
+++ b/mani/lib/firebase/storage.ts
@@ -1,5 +1,5 @@
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage'
-import { privateStorage } from './init'
+import { privateStorage, storage } from './init'
export const uploadPrivateImage = async (
userId: string,
@@ -23,3 +23,23 @@ export const uploadPrivateImage = async (
throw error
}
}
+
+export const uploadPublicImage = async (
+ username: string,
+ uri: string,
+ fileName: string
+): Promise => {
+ try {
+ const storageRef = ref(storage, `public-images/${username}/${fileName}`)
+
+ // Fetch the image and get it as a blob
+ const response = await fetch(uri)
+ const blob = await response.blob()
+
+ await uploadBytes(storageRef, blob)
+ return getDownloadURL(storageRef)
+ } catch (error) {
+ console.error('Error uploading image:', error)
+ throw error
+ }
+}
diff --git a/mani/package.json b/mani/package.json
index 642b5c879c..fc2dcfb9f0 100644
--- a/mani/package.json
+++ b/mani/package.json
@@ -39,6 +39,7 @@
"expo-apple-authentication": "~7.1.3",
"expo-auth-session": "~6.0.2",
"expo-blur": "~14.0.2",
+ "expo-clipboard": "~7.0.1",
"expo-constants": "~17.0.4",
"expo-dev-client": "~5.0.9",
"expo-file-system": "~18.0.7",
diff --git a/mani/yarn.lock b/mani/yarn.lock
index e592d4db95..99dbec561f 100644
--- a/mani/yarn.lock
+++ b/mani/yarn.lock
@@ -4595,6 +4595,11 @@ expo-blur@~14.0.2:
resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-14.0.2.tgz#1fe1f5c64cae9e28a3bee04764eee05318ed4672"
integrity sha512-6ZStKz/7F3nWfmfdeAzhJeNAtxPQAetU1FQ742XHX9uEfZjhq00CrAjyZNx2+nXpE3tGFQtXyhEE5hQJwug8yQ==
+expo-clipboard@~7.0.1:
+ version "7.0.1"
+ resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-7.0.1.tgz#31d61270e77a37d2a6b7ae9abf79e060497ef43b"
+ integrity sha512-rqYk0+WoqitPcPKxmMxSpLonX1E5Ije3LBYfnYMbH3xU5Gr8EAH9QnOWOi4BgahUPvcot6nbFEnx+DqARrmxKQ==
+
expo-constants@~17.0.4:
version "17.0.4"
resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-17.0.4.tgz#d0b653dc9a36fc0b25887c99a46d9806bdfe462d"