feat: implemented profile page

This commit is contained in:
Sabir Hassan 2024-03-01 15:16:35 +05:00
parent 8a1cf9264d
commit c0547b2a1f
15 changed files with 413 additions and 17 deletions

41
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@emotion/react": "11.11.4",
"@emotion/styled": "11.11.0",
"@mui/icons-material": "5.15.11",
"@mui/lab": "5.0.0-alpha.166",
"@mui/material": "5.15.11",
"@nostr-dev-kit/ndk": "2.5.0",
"@reduxjs/toolkit": "2.2.1",
@ -1245,6 +1246,46 @@
}
}
},
"node_modules/@mui/lab": {
"version": "5.0.0-alpha.166",
"resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.166.tgz",
"integrity": "sha512-a+0yorrgxLIgfKhShVKQk0/5CnB4KBhMQ64SvEB+CsvKAKKJzjIU43m2nMqdBbWzfnEuj6wR9vQ9kambdn3ZKA==",
"dependencies": {
"@babel/runtime": "^7.23.9",
"@mui/base": "5.0.0-beta.37",
"@mui/system": "^5.15.11",
"@mui/types": "^7.2.13",
"@mui/utils": "^5.15.11",
"clsx": "^2.1.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@mui/material": ">=5.15.0",
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/material": {
"version": "5.15.11",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.11.tgz",

View File

@ -13,6 +13,7 @@
"@emotion/react": "11.11.4",
"@emotion/styled": "11.11.0",
"@mui/icons-material": "5.15.11",
"@mui/lab": "5.0.0-alpha.166",
"@mui/material": "5.15.11",
"@nostr-dev-kit/ndk": "2.5.0",
"@reduxjs/toolkit": "2.2.1",

View File

@ -17,7 +17,7 @@ import Username from '../username'
import { Link, useNavigate } from 'react-router-dom'
import nostrichAvatar from '../../assets/images/avatar.png'
import nostrichLogo from '../../assets/images/nostr-logo.jpg'
import { appPublicRoutes } from '../../routes'
import { appPublicRoutes, getProfileRoute } from '../../routes'
import { shorten } from '../../utils'
import styles from './style.module.scss'
@ -50,10 +50,18 @@ export const AppBar = () => {
setAnchorElUser(null)
}
const handleProfile = () => {
const hexKey = authState?.usersPubkey
if (hexKey) navigate(getProfileRoute(hexKey))
setAnchorElUser(null)
}
const handleLogout = () => {
dispatch(
setAuthState({
loggedIn: false,
usersPubkey: undefined,
loginMethod: undefined
})
)
@ -111,6 +119,7 @@ export const AppBar = () => {
>
<Typography variant='h5'>{username}</Typography>
</MenuItem>
<MenuItem onClick={handleProfile}>Profile</MenuItem>
<Link
to={appPublicRoutes.help}
target='_blank'

View File

@ -48,7 +48,8 @@ export class AuthController {
store.dispatch(
setAuthState({
loggedIn: true
loggedIn: true,
usersPubkey: pubkey
})
)

View File

@ -4,18 +4,25 @@ import {
VerifiedEvent,
kinds,
validateEvent,
verifyEvent
verifyEvent,
Event
} from 'nostr-tools'
import { ProfileMetadata } from '../types'
import { NostrController } from '.'
import { toast } from 'react-toastify'
export class MetadataController {
constructor() {}
private nostrController: NostrController
private specialMetadataRelay = 'wss://purplepag.es'
constructor() {
this.nostrController = NostrController.getInstance()
}
public findMetadata = async (hexKey: string) => {
const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS
const hardcodedPopularRelays = (mostPopularRelays || '').split(' ')
const specialMetadataRelay = 'wss://purplepag.es'
const relays = [...hardcodedPopularRelays, specialMetadataRelay]
const relays = [...hardcodedPopularRelays, this.specialMetadataRelay]
const eventFilter: Filter = {
kinds: [kinds.Metadata],
@ -44,10 +51,42 @@ export class MetadataController {
public extractProfileMetadataContent = (event: VerifiedEvent) => {
try {
return JSON.parse(event.content)
return JSON.parse(event.content) as ProfileMetadata
} catch (error) {
console.log('error in parsing metadata event content :>> ', error)
return null
}
}
/**
* Function will not sign provided event if the SIG exists
*/
public publishMetadataEvent = async (event: Event) => {
let signedMetadataEvent = event
if (event.sig.length < 1) {
const timestamp = Math.floor(Date.now() / 1000)
// Metadata event to publish to the nquiz relay
const newMetadataEvent: Event = {
...event,
created_at: timestamp
}
signedMetadataEvent = await this.nostrController.signEvent(
newMetadataEvent
)
}
await this.nostrController
.publishEvent(signedMetadataEvent, this.specialMetadataRelay)
.then((res) => {
toast.success(`Metadata: ${res}`)
})
.catch((err) => {
toast.error(err.message)
})
}
public validate = (event: Event) => validateEvent(event) && verifyEvent(event)
}

View File

@ -7,6 +7,7 @@ import NDK, {
import {
Event,
EventTemplate,
Relay,
UnsignedEvent,
finalizeEvent,
nip19
@ -184,6 +185,17 @@ export class NostrController {
return NostrController.instance
}
/**
* Function will publish provided event to the provided relay
*/
publishEvent = async (event: Event, relayUrl: string) => {
const relay = await Relay.connect(relayUrl)
await relay.publish(event)
relay.close()
return `event published to relay: ${relayUrl}`
}
/**
* Signs an event with private key (if it is present in local storage) or
* with browser extension (if it is present) or

View File

@ -135,7 +135,7 @@ export const Login = () => {
const metadataContent =
metadataController.extractProfileMetadataContent(metadataEvent)
if (!metadataContent.nip05) {
if (!metadataContent?.nip05) {
toast.error('nip05 not present in metadata')
return
}

252
src/pages/profile/index.tsx Normal file
View File

@ -0,0 +1,252 @@
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { List, ListItem, ListSubheader, TextField } from '@mui/material'
import { UnsignedEvent, nip19, kinds, VerifiedEvent } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import placeholderAvatar from '../../assets/images/nostr-logo.jpg'
import { MetadataController, NostrController } from '../../controllers'
import { ProfileMetadata } from '../../types'
import styles from './style.module.scss'
import { useDispatch, useSelector } from 'react-redux'
import { State } from '../../store/rootReducer'
import { LoadingButton } from '@mui/lab'
import { Dispatch } from '../../store/store'
import { setMetadataEvent } from '../../store/actions'
import { LoadingSpinner } from '../../components/LoadingSpinner'
export const ProfilePage = () => {
const { npub } = useParams()
const dispatch: Dispatch = useDispatch()
const metadataController = useMemo(() => new MetadataController(), [])
const nostrController = NostrController.getInstance()
const [pubkey, setPubkey] = useState<string>()
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>()
const [savingProfileMetadata, setSavingProfileMetadata] = useState(false)
const metadataState = useSelector((state: State) => state.metadata)
const keys = useSelector((state: State) => state.auth?.keyPair)
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc] = useState('Fetching metadata')
useEffect(() => {
if (npub) {
try {
const hexPubkey = nip19.decode(npub).data as string
setPubkey(hexPubkey)
if (hexPubkey === usersPubkey) setIsUsersOwnProfile(true)
} catch (error) {
toast.error('Error occurred in decoding npub' + error)
}
}
}, [npub, usersPubkey])
useEffect(() => {
if (isUsersOwnProfile && metadataState) {
const metadataContent = metadataController.extractProfileMetadataContent(
metadataState as VerifiedEvent
)
if (metadataContent) {
setProfileMetadata(metadataContent)
setIsLoading(false)
}
return
}
if (pubkey) {
const getMetadata = async (pubkey: string) => {
const metadataEvent = await metadataController
.findMetadata(pubkey)
.catch((err) => {
toast.error(err)
return null
})
if (metadataEvent) {
const metadataContent =
metadataController.extractProfileMetadataContent(metadataEvent)
if (metadataContent) setProfileMetadata(metadataContent)
}
setIsLoading(false)
}
getMetadata(pubkey)
}
}, [isUsersOwnProfile, metadataState, pubkey, metadataController])
const editItem = (
key: keyof ProfileMetadata,
label: string,
multiline = false,
rows = 1
) => (
<ListItem sx={{ marginTop: 1 }}>
<TextField
label={label}
id={label.split(' ').join('-')}
value={profileMetadata![key] || ''}
size='small'
multiline={multiline}
rows={rows}
className={styles.textField}
disabled={!isUsersOwnProfile}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target
setProfileMetadata((prev) => ({
...prev,
[key]: value
}))
}}
/>
</ListItem>
)
const copyItem = (
value: string,
label: string,
copyValue?: string,
isPassword = false
) => (
<ListItem
sx={{ marginTop: 1 }}
onClick={() => {
navigator.clipboard.writeText(copyValue || value)
toast.success('Copied to clipboard', {
autoClose: 1000,
hideProgressBar: true
})
}}
>
<TextField
label={label}
id={label.split(' ').join('-')}
defaultValue={value}
size='small'
className={styles.textField}
disabled
type={isPassword ? 'password' : 'text'}
/>
<ContentCopyIcon className={styles.copyItem} />
</ListItem>
)
const handleSaveMetadata = async () => {
setSavingProfileMetadata(true)
const content = JSON.stringify(profileMetadata)
// We need to omit cachedAt and create new event
// Relay will reject if created_at is too late
const updatedMetadataState: UnsignedEvent = {
content: content,
created_at: Math.round(Date.now() / 1000),
kind: kinds.Metadata,
pubkey: pubkey!,
tags: metadataState?.tags || []
}
const signedEvent = await nostrController
.signEvent(updatedMetadataState)
.catch((error) => {
toast.error(`Error saving profile metadata. ${error}`)
})
if (signedEvent) {
if (!metadataController.validate(signedEvent)) {
toast.error(`Metadata is not valid.`)
}
await metadataController.publishMetadataEvent(signedEvent)
dispatch(setMetadataEvent(signedEvent))
}
setSavingProfileMetadata(false)
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<div className={styles.container}>
<List
sx={{
bgcolor: 'background.paper',
marginTop: 2
}}
subheader={
<ListSubheader
sx={{
paddingBottom: 1,
paddingTop: 1,
fontSize: '1.5rem'
}}
className={styles.subHeader}
>
Profile Settings
</ListSubheader>
}
>
{profileMetadata && (
<div>
<ListItem
sx={{
marginTop: 1,
display: 'flex',
flexDirection: 'column',
gap: 2
}}
>
<img
onError={(event: any) => {
event.target.src = placeholderAvatar
}}
className={styles.img}
src={profileMetadata.picture || placeholderAvatar}
alt='Profile Image'
/>
</ListItem>
{editItem('name', 'Username')}
{editItem('display_name', 'Display Name')}
{editItem('nip05', 'Nostr Address (nip05)')}
{editItem('lud16', 'Lightning Address (lud16)')}
{editItem('about', 'About', true, 4)}
{isUsersOwnProfile && (
<>
{keys && keys.public && copyItem(keys.public, 'Public Key')}
{keys &&
keys.private &&
copyItem(
'••••••••••••••••••••••••••••••••••••••••••••••••••',
'Private Key',
keys.private
)}
</>
)}
</div>
)}
</List>
{isUsersOwnProfile && (
<LoadingButton
loading={savingProfileMetadata}
variant='contained'
onClick={handleSaveMetadata}
>
SAVE
</LoadingButton>
)}
</div>
</>
)
}

View File

@ -0,0 +1,23 @@
.container {
display: flex;
flex-direction: column;
gap: 25px;
}
.textField {
width: 100%;
}
.subHeader {
border-bottom: 0.5px solid;
}
.img {
max-height: 40%;
max-width: 40%;
}
.copyItem {
margin-left: 10px;
color: #34495e;
}

View File

@ -1,11 +1,17 @@
import { LandingPage } from '../pages/landing/LandingPage'
import { Login } from '../pages/login'
import { ProfilePage } from '../pages/profile'
import { hexToNpub } from '../utils'
export const appPublicRoutes = {
profile: '/profile/:npub',
login: '/login',
help: 'https://help.sigit.io'
}
export const getProfileRoute = (hexKey: string) =>
appPublicRoutes.profile.replace(':npub', hexToNpub(hexKey))
export const appPrivateRoutes = {
homePage: '/'
}
@ -15,6 +21,10 @@ export const publicRoutes = [
path: appPublicRoutes.login,
hiddenWhenLoggedIn: true,
element: <Login />
},
{
path: appPublicRoutes.profile,
element: <ProfilePage />
}
]

View File

@ -14,6 +14,7 @@ export interface Keys {
export interface AuthState {
loggedIn: boolean
usersPubkey?: string
loginMethod?: LoginMethods
keyPair?: Keys
nsecBunkerPubkey?: string

View File

@ -1 +1,2 @@
export * from './nostr'
export * from './profile'

8
src/types/profile.ts Normal file
View File

@ -0,0 +1,8 @@
export interface ProfileMetadata {
name?: string
display_name?: string
picture?: string
about?: string
nip05?: string
lud16?: string
}

View File

@ -1,8 +0,0 @@
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
nostr: any
}
}
export {}

View File

@ -62,6 +62,12 @@ export const nsecToHex = (nsec: string): string | null => {
return null
}
export const hexToNpub = (hexPubkey: string | undefined): string => {
if (!hexPubkey) return 'n/a'
return nip19.npubEncode(hexPubkey)
}
export const verifySignedEvent = (event: SignedEvent) => {
const isGood = verifyEvent(event)