diff --git a/src/index.css b/src/index.css index 3f10537..a9ed198 100644 --- a/src/index.css +++ b/src/index.css @@ -71,7 +71,7 @@ button { } .main { - padding: 64px 0; + padding: 60px 0; } .hide-mobile { diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 318f115..caa5387 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -1,50 +1,42 @@ 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, hexToNpub, 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 { 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 +44,6 @@ export const ProfilePage = () => { const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc] = useState('Fetching metadata') - const robotSet = useRef(1) - useEffect(() => { if (npub) { try { @@ -116,130 +106,31 @@ export const ProfilePage = () => { } }, [isUsersOwnProfile, metadataState, pubkey, metadataController]) - const editItem = ( - key: keyof ProfileMetadata, - label: string, - multiline = false, - rows = 1, - inputProps?: InputProps - ) => ( - - ) => { - const { value } = event.target + /** + * Rendering text with button which copies the provided text + * @param text to be visible + * @param sx props (MUI) to customize style + * @returns HTML rendered text + */ + const textElementWithCopyIcon = ( + text: string, + sx?: SxProps, + shortenOffset?: number + ) => { + 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, shortenOffset)} + + ) } @@ -250,6 +141,8 @@ export const ProfilePage = () => { * @returns robohash image url */ const getProfileImage = (metadata: ProfileMetadata) => { + if (!metadata) return '' + if (!isUsersOwnProfile) { return metadata.picture || getRoboHashPicture(npub!) } @@ -262,96 +155,124 @@ export const ProfilePage = () => { return ( <> {isLoading && } -
- - Profile Settings - - } - > - {profileMetadata && ( -
- - { - event.target.src = getRoboHashPicture(npub!) - }} - className={styles.img} - src={getProfileImage(profileMetadata)} - alt="Profile Image" - /> + {pubkey && ( + + + {profileMetadata && profileMetadata.banner ? ( + + ) : ( + '' + )} + - {nostrJoiningBlock && ( - + + +
+ +
+
+ + + {nostrJoiningBlock + ? `On nostr since ${nostrJoiningBlock.block.toLocaleString()}` + : 'On nostr since: unknown'} + + + + {isUsersOwnProfile && ( + navigate(getProfileSettingsRoute(pubkey))} > - On nostr since {nostrJoiningBlock.block.toLocaleString()} + + + )} + +
+ + + + {profileMetadata && ( + + {truncate( + profileMetadata.display_name || + profileMetadata.name || + hexToNpub(pubkey), + { + length: 16 + } + )} + + )} + + + {textElementWithCopyIcon( + hexToNpub(pubkey) || '', + undefined, + 15 + )} + + + {profileMetadata?.nip05 && + textElementWithCopyIcon(profileMetadata.nip05, undefined, 15)} + + + {profileMetadata?.lud16 && + textElementWithCopyIcon(profileMetadata.lud16, undefined, 15)} + + + + + {profileMetadata?.website && ( + + {profileMetadata.website} )} -
- - {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 - )} - + + + + {profileMetadata?.about && ( + + {profileMetadata.about} + )} -
- )} -
- {isUsersOwnProfile && ( - - SAVE - - )} -
+ + + + )} ) } diff --git a/src/pages/profile/style.module.scss b/src/pages/profile/style.module.scss index d5d3282..c3c2034 100644 --- a/src/pages/profile/style.module.scss +++ b/src/pages/profile/style.module.scss @@ -1,23 +1,102 @@ -.container { +.banner { display: flex; - flex-direction: column; - gap: 25px; -} - -.textField { + align-items: center; + justify-content: center; width: 100%; + min-height: 210px; + + img { + width: 100%; + } + + &.noImage { + background-color: rgb(219, 219, 219); + } } -.subHeader { - border-bottom: 0.5px solid; +.belowBanner { + padding: 0 15px; } -.img { - max-height: 40%; - max-width: 40%; +.upper { + width: 100%; + display: flex; } -.copyItem { +.container { + color: black +} + +.left { + margin-right: 10px; + margin-top: -35px; +} + +.middle { + flex: 1; +} + +.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; +} + +.link { + &:hover { + color: #3a3a3a; + } +} + +.nostrSince { + color: #5c5c5c; + margin-top: 15px !important; + display: inline-block; + + &:hover { + color: #3a3a3a; + } +} + +.website { + margin-top: 8px !important; + text-decoration: underline; + text-decoration-color: #3e3e3e; + color: #3e3e3e; +} + +.captionWrapper { + display: flex; + align-items: center; +} + +.captionIcon { + color: #15999b; + margin-left: 5px; + font-size: 12px; +} + +.npubNipItem { + display: inline-flex; + justify-content: space-between; + color: #3e3e3e; + line-height: 1.3; + + .copyIcon { + font-size: 0.9rem !important; + margin-left: 5px; + margin-top: 2px; + } +} \ 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..9b68872 --- /dev/null +++ b/src/pages/settings/profile/index.tsx @@ -0,0 +1,378 @@ +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 } 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 && ( +
+ + {profileMetadata.banner ? ( + Banner Image + ) : ( + No banner found + )} + + + {editItem('banner', 'Banner URL', undefined, undefined)} + + + { + 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..672e59c --- /dev/null +++ b/src/pages/settings/profile/style.module.scss @@ -0,0 +1,37 @@ +.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; +} + +.noBanner { + width: 100%; + height: 180px; + background: rgb(219, 219, 219); + color: rgb(88, 88, 88); + display: flex; + justify-content: center; + align-items: center; +} + +.bannerImg { + width: 100%; +} \ No newline at end of file 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: } ] diff --git a/src/types/profile.ts b/src/types/profile.ts index e65d602..abb9422 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -2,6 +2,7 @@ export interface ProfileMetadata { name?: string display_name?: string picture?: string + banner?: string about?: string website?: string nip05?: string diff --git a/src/utils/string.ts b/src/utils/string.ts index 20337ea..97e86ea 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -1,3 +1,10 @@ +/** + * Function will replace the middle of the string with 3 dots if length greater then + * offset value + * @param str string to shorten + * @param offset of how many chars to keep in the beginning and the end + * eg. 3 will keep first 3 chars and last 3 chars between the dots + */ export const shorten = (str: string, offset = 9) => { // return original string if it is not long enough if (str.length < offset * 2 + 4) return str