import ContentCopyIcon from '@mui/icons-material/ContentCopy' import { Box, IconButton, InputProps, List, ListItem, ListSubheader, TextField, Tooltip, Typography, useTheme } from '@mui/material' import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools' import { useEffect, useMemo, useRef, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { toast } from 'react-toastify' import { MetadataController, NostrController } from '../../../controllers' import { NostrJoiningBlock, 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' import { LoginMethods } from '../../../store/auth/types' import { SmartToy } from '@mui/icons-material' import { getRoboHashPicture } from '../../../utils' export const ProfileSettingsPage = () => { const theme = useTheme() const { npub } = useParams() const dispatch: Dispatch = useDispatch() const metadataController = useMemo(() => new MetadataController(), []) const nostrController = NostrController.getInstance() const [pubkey, setPubkey] = useState<string>() const [nostrJoiningBlock, setNostrJoiningBlock] = useState<NostrJoiningBlock | null>(null) 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, loginMethod } = useSelector((state: State) => state.auth) const userRobotImage = useSelector((state: State) => state.userRobotImage) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc] = useState('Fetching metadata') const robotSet = useRef(1) 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 (pubkey) { metadataController .getNostrJoiningBlockNumber(pubkey) .then((res) => { setNostrJoiningBlock(res) }) .catch((err) => { // todo: handle error console.log('err :>> ', err) }) } 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 handleMetadataEvent = (event: Event) => { const metadataContent = metadataController.extractProfileMetadataContent(event) if (metadataContent) { setProfileMetadata(metadataContent) } } metadataController.on(pubkey, (kind: number, event: Event) => { if (kind === kinds.Metadata) { handleMetadataEvent(event) } }) const metadataEvent = await metadataController .findMetadata(pubkey) .catch((err) => { toast.error(err) return null }) if (metadataEvent) handleMetadataEvent(metadataEvent) setIsLoading(false) } getMetadata(pubkey) } }, [isUsersOwnProfile, metadataState, pubkey, metadataController]) const editItem = ( key: keyof ProfileMetadata, label: string, multiline = false, rows = 1, inputProps?: InputProps ) => ( <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} InputProps={inputProps} 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'} InputProps={{ endAdornment: <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) } /** * Called by clicking on the robot icon inside Picture URL input * On every click, next robohash set will be generated. * There are 5 sets at the moment, after 5th set function will start over from set 1. */ const generateRobotAvatar = () => { robotSet.current++ if (robotSet.current > 5) robotSet.current = 1 const robotAvatarLink = getRoboHashPicture(npub!, robotSet.current) setProfileMetadata((prev) => ({ ...prev, picture: robotAvatarLink })) } /** * * @returns robohash generate button, loading spinner or no button */ const robohashButton = () => { return ( <Tooltip title="Generate a robohash avatar"> <IconButton onClick={generateRobotAvatar}> <SmartToy /> </IconButton> </Tooltip> ) } /** * Handles the logic for Image URL. * If no picture in kind 0 found - use robohash avatar * * @returns robohash image url */ const getProfileImage = (metadata: ProfileMetadata) => { if (!isUsersOwnProfile) { return metadata.picture || getRoboHashPicture(npub!) } // userRobotImage is used only when visiting own profile // while kind 0 picture is not set return metadata.picture || userRobotImage || getRoboHashPicture(npub!) } 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' }} > {profileMetadata.banner ? ( <img className={styles.bannerImg} src={profileMetadata.banner} alt="Banner Image" /> ) : ( <Box className={styles.noBanner}> No banner found </Box> )} </ListItem> {editItem('banner', 'Banner URL', undefined, undefined)} <ListItem sx={{ marginTop: 1, display: 'flex', flexDirection: 'column' }} > <img onError={(event: any) => { event.target.src = getRoboHashPicture(npub!) }} className={styles.img} src={getProfileImage(profileMetadata)} alt="Profile Image" /> {nostrJoiningBlock && ( <Typography sx={{ color: theme.palette.getContrastText( theme.palette.background.paper ) }} component={Link} to={`https://njump.me/${nostrJoiningBlock.encodedEventPointer}`} target="_blank" > On nostr since {nostrJoiningBlock.block.toLocaleString()} </Typography> )} </ListItem> {editItem('picture', 'Picture URL', undefined, undefined, { endAdornment: isUsersOwnProfile ? robohashButton() : undefined })} {editItem('name', 'Username')} {editItem('display_name', 'Display Name')} {editItem('nip05', 'Nostr Address (nip05)')} {editItem('lud16', 'Lightning Address (lud16)')} {editItem('about', 'About', true, 4)} {editItem('website', 'Website')} {isUsersOwnProfile && ( <> {usersPubkey && copyItem(nip19.npubEncode(usersPubkey), 'Public Key')} {loginMethod === LoginMethods.privateKey && keys && keys.private && copyItem( '••••••••••••••••••••••••••••••••••••••••••••••••••', 'Private Key', keys.private )} </> )} </div> )} </List> {isUsersOwnProfile && ( <LoadingButton loading={savingProfileMetadata} variant="contained" onClick={handleSaveMetadata} > SAVE </LoadingButton> )} </div> </> ) }