diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 318f115..a7da30f 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -1,50 +1,43 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy' import { + Box, IconButton, - InputProps, - List, - ListItem, - ListSubheader, - TextField, - Tooltip, + SxProps, Typography, - useTheme + useTheme, + Theme } from '@mui/material' -import { UnsignedEvent, nip19, kinds, VerifiedEvent } from 'nostr-tools' -import { useEffect, useMemo, useRef, useState } from 'react' -import { Link, useParams } from 'react-router-dom' +import { nip19, VerifiedEvent } from 'nostr-tools' +import { useEffect, useMemo, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' import { toast } from 'react-toastify' -import { MetadataController, NostrController } from '../../controllers' +import { MetadataController } from '../../controllers' import { NostrJoiningBlock, ProfileMetadata } from '../../types' import styles from './style.module.scss' -import { useDispatch, useSelector } from 'react-redux' +import { 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 { getRoboHashPicture, shorten } from '../../utils' +import { truncate } from 'lodash' +import { getProfileSettingsRoute } from '../../routes' +import EditIcon from '@mui/icons-material/Edit'; +import LinkIcon from '@mui/icons-material/Link'; +import RestoreIcon from '@mui/icons-material/Restore'; import { LoadingSpinner } from '../../components/LoadingSpinner' -import { LoginMethods } from '../../store/auth/types' -import { SmartToy } from '@mui/icons-material' -import { getRoboHashPicture } from '../../utils' export const ProfilePage = () => { + const navigate = useNavigate() const theme = useTheme() const { npub } = useParams() - const dispatch: Dispatch = useDispatch() - const metadataController = useMemo(() => new MetadataController(), []) - const nostrController = NostrController.getInstance() const [pubkey, setPubkey] = useState() const [nostrJoiningBlock, setNostrJoiningBlock] = useState(null) const [profileMetadata, setProfileMetadata] = useState() - 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 { usersPubkey } = useSelector((state: State) => state.auth) const userRobotImage = useSelector((state: State) => state.userRobotImage) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) @@ -52,8 +45,6 @@ export const ProfilePage = () => { const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc] = useState('Fetching metadata') - const robotSet = useRef(1) - useEffect(() => { if (npub) { try { @@ -116,133 +107,30 @@ export const ProfilePage = () => { } }, [isUsersOwnProfile, metadataState, pubkey, metadataController]) - const editItem = ( - key: keyof ProfileMetadata, - label: string, - multiline = false, - rows = 1, - inputProps?: InputProps - ) => ( - - ) => { - const { value } = event.target + const textElementWithCopyIcon = (text: string) => { + const onClick = () => { + navigator.clipboard.writeText(text) - setProfileMetadata((prev) => ({ - ...prev, - [key]: value - })) - }} - /> - - ) - - const copyItem = ( - value: string, - label: string, - copyValue?: string, - isPassword = false - ) => ( - { - navigator.clipboard.writeText(copyValue || value) - - toast.success('Copied to clipboard', { - autoClose: 1000, - hideProgressBar: true - }) - }} - > - - }} - /> - - ) - - 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}`) + toast.success('Copied to clipboard', { + autoClose: 1000, + hideProgressBar: true }) - - 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 ( - - - - - + + {shorten(text)} + + ) } + const titleElement = (text: string, sx?: SxProps) => ( + + {text} + + ) + /** * Handles the logic for Image URL. * If no picture in kind 0 found - use robohash avatar @@ -250,6 +138,8 @@ export const ProfilePage = () => { * @returns robohash image url */ const getProfileImage = (metadata: ProfileMetadata) => { + if (!metadata) return '' + if (!isUsersOwnProfile) { return metadata.picture || getRoboHashPicture(npub!) } @@ -262,96 +152,101 @@ export const ProfilePage = () => { return ( <> {isLoading && } -
- - Profile Settings - - } - > - {profileMetadata && ( -
- + + +
{ - event.target.src = getRoboHashPicture(npub!) - }} - className={styles.img} - src={getProfileImage(profileMetadata)} - alt="Profile Image" + className={styles['image-placeholder']} + src={getProfileImage(profileMetadata!)} /> - - {nostrJoiningBlock && ( - + + + + + - On nostr since {nostrJoiningBlock.block.toLocaleString()} - - )} - + {profileMetadata && + titleElement( + truncate( + profileMetadata.display_name || + profileMetadata.name || + pubkey, + { + length: 16 + } + ), + { marginRight: 1 } + )} + + + {textElementWithCopyIcon(pubkey || '')} + + + {profileMetadata?.nip05 && + textElementWithCopyIcon(profileMetadata.nip05)} + + - {editItem('picture', 'Picture URL', undefined, undefined, { - endAdornment: isUsersOwnProfile ? robohashButton() : undefined - })} + + {profileMetadata?.website && ( + + + {profileMetadata.website} + + )} + - {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')} + + {nostrJoiningBlock && ( + + + On nostr since {nostrJoiningBlock.block.toLocaleString()} + + )} + + + + {isUsersOwnProfile && ( - <> - {usersPubkey && - copyItem(nip19.npubEncode(usersPubkey), 'Public Key')} - {loginMethod === LoginMethods.privateKey && - keys && - keys.private && - copyItem( - '••••••••••••••••••••••••••••••••••••••••••••••••••', - 'Private Key', - keys.private - )} - + navigate(getProfileSettingsRoute(pubkey))}> + + )} -
- )} - - {isUsersOwnProfile && ( - - SAVE - - )} -
+ + + + {profileMetadata?.about && ( + + {profileMetadata.about} + + )} + + + )} ) } diff --git a/src/pages/profile/style.module.scss b/src/pages/profile/style.module.scss index d5d3282..3e1afcc 100644 --- a/src/pages/profile/style.module.scss +++ b/src/pages/profile/style.module.scss @@ -1,23 +1,51 @@ -.container { +.upper { display: flex; - flex-direction: column; - gap: 25px; + padding-top: 15px; } -.textField { - width: 100%; +.container { + color: black } -.subHeader { - border-bottom: 0.5px solid; +.left { + margin-right: 10px; } -.img { - max-height: 40%; - max-width: 40%; -} - -.copyItem { +.right { margin-left: 10px; - color: #34495e; } + +.imageWrapper { + overflow: hidden; + border-radius: 50%; + width: 80px; + height: 80px; + display: flex; + justify-content: center; + align-items: center; +} + +.image-placeholder { + width: 150px; +} + +.copyIcon { + font-size: 1.1rem !important; + margin-left: 5px; +} + +.website { + // margin-top: 10px !important; + margin-bottom: 15px 0 !important; +} + +.captionWrapper { + display: flex; + align-items: center; +} + +.captionIcon { + color: #15999b; + margin-right: 10px; + font-size: 12px; +} \ No newline at end of file diff --git a/src/pages/settings/profile/index.tsx b/src/pages/settings/profile/index.tsx new file mode 100644 index 0000000..86799ed --- /dev/null +++ b/src/pages/settings/profile/index.tsx @@ -0,0 +1,357 @@ +import ContentCopyIcon from '@mui/icons-material/ContentCopy' +import { + IconButton, + InputProps, + List, + ListItem, + ListSubheader, + TextField, + Tooltip, + Typography, + useTheme +} from '@mui/material' +import { UnsignedEvent, nip19, kinds, VerifiedEvent } 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() + const [nostrJoiningBlock, setNostrJoiningBlock] = + useState(null) + const [profileMetadata, setProfileMetadata] = useState() + 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 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, + inputProps?: InputProps + ) => ( + + ) => { + const { value } = event.target + + setProfileMetadata((prev) => ({ + ...prev, + [key]: value + })) + }} + /> + + ) + + const copyItem = ( + value: string, + label: string, + copyValue?: string, + isPassword = false + ) => ( + { + navigator.clipboard.writeText(copyValue || value) + + toast.success('Copied to clipboard', { + autoClose: 1000, + hideProgressBar: true + }) + }} + > + + }} + /> + + ) + + 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 ( + + + + + + ) + } + + /** + * 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 && } +
+ + Profile Settings + + } + > + {profileMetadata && ( +
+ + { + event.target.src = getRoboHashPicture(npub!) + }} + className={styles.img} + src={getProfileImage(profileMetadata)} + alt="Profile Image" + /> + + {nostrJoiningBlock && ( + + On nostr since {nostrJoiningBlock.block.toLocaleString()} + + )} + + + {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 + )} + + )} +
+ )} +
+ {isUsersOwnProfile && ( + + SAVE + + )} +
+ + ) +} diff --git a/src/pages/settings/profile/style.module.scss b/src/pages/settings/profile/style.module.scss new file mode 100644 index 0000000..d5d3282 --- /dev/null +++ b/src/pages/settings/profile/style.module.scss @@ -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; +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 1796e19..a88732f 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -6,12 +6,14 @@ import { ProfilePage } from '../pages/profile' import { hexToNpub } from '../utils' import { SignPage } from '../pages/sign' import { VerifyPage } from '../pages/verify' +import { ProfileSettingsPage } from '../pages/settings/profile' export const appPrivateRoutes = { homePage: '/', create: '/create', sign: '/sign', - verify: '/verify' + verify: '/verify', + profileSettings: '/settings/profile/:npub' } export const appPublicRoutes = { @@ -24,6 +26,9 @@ export const appPublicRoutes = { export const getProfileRoute = (hexKey: string) => appPublicRoutes.profile.replace(':npub', hexToNpub(hexKey)) +export const getProfileSettingsRoute = (hexKey: string) => + appPrivateRoutes.profileSettings.replace(':npub', hexToNpub(hexKey)) + export const publicRoutes = [ { path: appPublicRoutes.landingPage, @@ -57,5 +62,9 @@ export const privateRoutes = [ { path: appPrivateRoutes.verify, element: + }, + { + path: appPrivateRoutes.profileSettings, + element: } ]