diff --git a/package-lock.json b/package-lock.json index 3c60c20..b4d8205 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 77acffb..233655e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index 8b1227a..1cb90ff 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -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 = () => { > {username} + Profile { 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) } diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts index e0c9882..1605aea 100644 --- a/src/controllers/NostrController.ts +++ b/src/controllers/NostrController.ts @@ -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 diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 3208281..87e2ecf 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -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 } diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx new file mode 100644 index 0000000..ab61a3f --- /dev/null +++ b/src/pages/profile/index.tsx @@ -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() + 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 = 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 + ) => ( + + ) => { + 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) + } + + return ( + <> + {isLoading && } +
+ + Profile Settings + + } + > + {profileMetadata && ( +
+ + { + event.target.src = placeholderAvatar + }} + className={styles.img} + src={profileMetadata.picture || placeholderAvatar} + alt='Profile Image' + /> + + + {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 + )} + + )} +
+ )} +
+ {isUsersOwnProfile && ( + + SAVE + + )} +
+ + ) +} diff --git a/src/pages/profile/style.module.scss b/src/pages/profile/style.module.scss new file mode 100644 index 0000000..d5d3282 --- /dev/null +++ b/src/pages/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 59cfa39..7693b34 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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: + }, + { + path: appPublicRoutes.profile, + element: } ] diff --git a/src/store/auth/types.ts b/src/store/auth/types.ts index 12d35f3..41e76be 100644 --- a/src/store/auth/types.ts +++ b/src/store/auth/types.ts @@ -14,6 +14,7 @@ export interface Keys { export interface AuthState { loggedIn: boolean + usersPubkey?: string loginMethod?: LoginMethods keyPair?: Keys nsecBunkerPubkey?: string diff --git a/src/types/index.ts b/src/types/index.ts index e69da0f..3002541 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1 +1,2 @@ export * from './nostr' +export * from './profile' diff --git a/src/types/profile.ts b/src/types/profile.ts new file mode 100644 index 0000000..46f120f --- /dev/null +++ b/src/types/profile.ts @@ -0,0 +1,8 @@ +export interface ProfileMetadata { + name?: string + display_name?: string + picture?: string + about?: string + nip05?: string + lud16?: string +} diff --git a/src/types/system/index.ts b/src/types/system/index.ts deleted file mode 100644 index 74b97a9..0000000 --- a/src/types/system/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare global { - interface Window { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - nostr: any - } -} - -export {} diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 0689360..b81ccef 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -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)