diff --git a/src/contexts/NDKContext.tsx b/src/contexts/NDKContext.tsx index edbb28a..3075f5e 100644 --- a/src/contexts/NDKContext.tsx +++ b/src/contexts/NDKContext.tsx @@ -198,7 +198,9 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => { const user = new NDKUser({ npub }) user.ndk = ndk - return await user.fetchProfile() + return await user.fetchProfile({ + cacheUsage: NDKSubscriptionCacheUsage.PARALLEL + }) } const publish = async ( diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 7a2f720..e50bb30 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -1,48 +1,49 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { toast } from 'react-toastify' + import ContentCopyIcon from '@mui/icons-material/ContentCopy' import EditIcon from '@mui/icons-material/Edit' import { Box, IconButton, SxProps, Theme, Typography } from '@mui/material' -import { Event, VerifiedEvent, kinds, nip19 } from 'nostr-tools' -import { useEffect, useMemo, useState } from 'react' -import { useAppSelector } from '../../hooks/store' -import { Link, useNavigate, useParams } from 'react-router-dom' -import { toast } from 'react-toastify' + +import { nip19 } from 'nostr-tools' + +import { Container } from '../../components/Container' +import { Footer } from '../../components/Footer/Footer' import { LoadingSpinner } from '../../components/LoadingSpinner' -import { MetadataController } from '../../controllers' +import { useAppSelector } from '../../hooks/store' + import { getProfileSettingsRoute } from '../../routes' -import { NostrJoiningBlock, ProfileMetadata } from '../../types' + import { - getNostrJoiningBlockNumber, getProfileUsername, getRoboHashPicture, hexToNpub, shorten } from '../../utils' + +import { NDKEvent, NDKUserProfile, profileFromEvent } from '@nostr-dev-kit/ndk' +import { useNDKContext } from '../../hooks' import styles from './style.module.scss' -import { Container } from '../../components/Container' -import { Footer } from '../../components/Footer/Footer' export const ProfilePage = () => { const navigate = useNavigate() const { npub } = useParams() - - const metadataController = useMemo(() => MetadataController.getInstance(), []) + const { ndk, findMetadata } = useNDKContext() const [pubkey, setPubkey] = useState() - const [nostrJoiningBlock, setNostrJoiningBlock] = - useState(null) - const [profileMetadata, setProfileMetadata] = useState() + const [userProfile, setUserProfile] = useState(null) + + const userRobotImage = useAppSelector((state) => state.userRobotImage) const metadataState = useAppSelector((state) => state.metadata) const { usersPubkey } = useAppSelector((state) => state.auth) - const userRobotImage = useAppSelector((state) => state.userRobotImage) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc] = useState('Fetching metadata') - const profileName = pubkey && getProfileUsername(pubkey, profileMetadata) - useEffect(() => { if (npub) { try { @@ -57,60 +58,30 @@ export const ProfilePage = () => { }, [npub, usersPubkey]) useEffect(() => { - if (pubkey) { - 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) - } + const ndkEvent = new NDKEvent(ndk, metadataState) + const profile = profileFromEvent(ndkEvent) + + setUserProfile(profile) + + 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) - } + findMetadata(pubkey) + .then((profile) => { + setUserProfile(profile) + }) + .catch((err) => { + toast.error(err) + }) + .finally(() => { + setIsLoading(false) }) - - 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]) + }, [ndk, isUsersOwnProfile, metadataState, pubkey, findMetadata]) /** * Rendering text with button which copies the provided text @@ -146,29 +117,32 @@ export const ProfilePage = () => { * * @returns robohash image url */ - const getProfileImage = (metadata: ProfileMetadata) => { - if (!metadata) return '' + const getProfileImage = (profile: NDKUserProfile | null) => { + if (!profile) return getRoboHashPicture(npub) if (!isUsersOwnProfile) { - return metadata.picture || getRoboHashPicture(npub!) + return profile.image || getRoboHashPicture(npub!) } // userRobotImage is used only when visiting own profile // while kind 0 picture is not set - return metadata.picture || userRobotImage || getRoboHashPicture(npub!) + return profile.image || userRobotImage || getRoboHashPicture(npub!) } + const profileName = + pubkey && getProfileUsername(pubkey, userProfile || undefined) + return ( <> {isLoading && } {pubkey && ( - {profileMetadata && profileMetadata.banner ? ( + {userProfile && userProfile.banner ? ( {`banner ) : ( @@ -189,24 +163,12 @@ export const ProfilePage = () => { > {profileName} - - - {nostrJoiningBlock - ? `On nostr since ${nostrJoiningBlock.block.toLocaleString()}` - : 'On nostr since: unknown'} - - + {isUsersOwnProfile && ( { display: 'flex' }} > - {profileMetadata && ( - - {profileName} - - )} + + {profileName} + {textElementWithCopyIcon( @@ -242,42 +202,34 @@ export const ProfilePage = () => { )} - {profileMetadata?.nip05 && - textElementWithCopyIcon( - profileMetadata.nip05, - undefined, - 15 - )} + {userProfile?.nip05 && + textElementWithCopyIcon(userProfile.nip05, undefined, 15)} - {profileMetadata?.lud16 && - textElementWithCopyIcon( - profileMetadata.lud16, - undefined, - 15 - )} + {userProfile?.lud16 && + textElementWithCopyIcon(userProfile.lud16, undefined, 15)} - {profileMetadata?.website && ( + {userProfile?.website && ( - {profileMetadata.website} + {userProfile.website} )} - {profileMetadata?.about && ( + {userProfile?.about && ( - {profileMetadata.about} + {userProfile.about} )} diff --git a/src/pages/settings/profile/index.tsx b/src/pages/settings/profile/index.tsx index 1a3e34f..8432406 100644 --- a/src/pages/settings/profile/index.tsx +++ b/src/pages/settings/profile/index.tsx @@ -1,4 +1,11 @@ +import React, { useEffect, useRef, useState } from 'react' +import { useParams } from 'react-router-dom' +import { toast } from 'react-toastify' + +import { SmartToy } from '@mui/icons-material' import ContentCopyIcon from '@mui/icons-material/ContentCopy' +import LaunchIcon from '@mui/icons-material/Launch' +import { LoadingButton } from '@mui/lab' import { Box, IconButton, @@ -7,59 +14,53 @@ import { ListItem, ListSubheader, TextField, - Tooltip, - Typography, - useTheme + Tooltip } from '@mui/material' -import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools' -import React, { useEffect, 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 { + NDKEvent, + NDKUserProfile, + profileFromEvent, + serializeProfile +} from '@nostr-dev-kit/ndk' +import { launch as launchNostrLoginDialog } from 'nostr-login' +import { kinds, nip19, UnsignedEvent } from 'nostr-tools' + +import { NostrController } from '../../../controllers' + +import { useNDKContext } from '../../../hooks' import { useAppDispatch, useAppSelector } from '../../../hooks/store' -import { LoadingButton } from '@mui/lab' -import { Dispatch } from '../../../store/store' -import { setMetadataEvent } from '../../../store/actions' -import { LoadingSpinner } from '../../../components/LoadingSpinner' -import { LoginMethod, NostrLoginAuthMethod } from '../../../store/auth/types' -import { SmartToy } from '@mui/icons-material' -import { - getNostrJoiningBlockNumber, - getRoboHashPicture, - unixNow -} from '../../../utils' +import { getRoboHashPicture, unixNow } from '../../../utils' + import { Container } from '../../../components/Container' import { Footer } from '../../../components/Footer/Footer' -import LaunchIcon from '@mui/icons-material/Launch' -import { launch as launchNostrLoginDialog } from 'nostr-login' +import { LoadingSpinner } from '../../../components/LoadingSpinner' + +import { setMetadataEvent } from '../../../store/actions' +import { LoginMethod, NostrLoginAuthMethod } from '../../../store/auth/types' +import { Dispatch } from '../../../store/store' + +import styles from './style.module.scss' export const ProfileSettingsPage = () => { - const theme = useTheme() - - const { npub } = useParams() - const dispatch: Dispatch = useAppDispatch() - const metadataController = MetadataController.getInstance() - const nostrController = NostrController.getInstance() + const { npub } = useParams() + const { ndk, findMetadata, publish } = useNDKContext() const [pubkey, setPubkey] = useState() - const [nostrJoiningBlock, setNostrJoiningBlock] = - useState(null) - const [profileMetadata, setProfileMetadata] = useState() - const [savingProfileMetadata, setSavingProfileMetadata] = useState(false) + const [userProfile, setUserProfile] = useState(null) + + const userRobotImage = useAppSelector((state) => state.userRobotImage) const metadataState = useAppSelector((state) => state.metadata) const keys = useAppSelector((state) => state.auth?.keyPair) const { usersPubkey, loginMethod, nostrLoginAuthMethod } = useAppSelector( (state) => state.auth ) - const userRobotImage = useAppSelector((state) => state.userRobotImage) + const [savingProfileMetadata, setSavingProfileMetadata] = useState(false) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) - const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc] = useState('Fetching metadata') @@ -79,63 +80,33 @@ export const ProfileSettingsPage = () => { }, [npub, usersPubkey]) useEffect(() => { - if (pubkey) { - 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) - } + const ndkEvent = new NDKEvent(ndk, metadataState) + const profile = profileFromEvent(ndkEvent) + + setUserProfile(profile) + + 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) - } + findMetadata(pubkey) + .then((profile) => { + setUserProfile(profile) + }) + .catch((err) => { + toast.error(err) + }) + .finally(() => { + setIsLoading(false) }) - - 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]) + }, [ndk, isUsersOwnProfile, metadataState, pubkey, findMetadata]) const editItem = ( - key: keyof ProfileMetadata, + key: keyof NDKUserProfile, label: string, multiline = false, rows = 1, @@ -145,7 +116,7 @@ export const ProfileSettingsPage = () => { { onChange={(event: React.ChangeEvent) => { const { value } = event.target - setProfileMetadata((prev) => ({ + setUserProfile((prev) => ({ ...prev, [key]: value })) @@ -197,32 +168,45 @@ export const ProfileSettingsPage = () => { ) const handleSaveMetadata = async () => { + if (!userProfile) return + setSavingProfileMetadata(true) - const content = JSON.stringify(profileMetadata) + const serializedProfile = serializeProfile(userProfile) - // We need to omit cachedAt and create new event - // Relay will reject if created_at is too late - const updatedMetadataState: UnsignedEvent = { - content: content, + const unsignedEvent: UnsignedEvent = { + content: serializedProfile, created_at: unixNow(), kind: kinds.Metadata, pubkey: pubkey!, - tags: metadataState?.tags || [] + tags: [] } + const nostrController = NostrController.getInstance() const signedEvent = await nostrController - .signEvent(updatedMetadataState) + .signEvent(unsignedEvent) .catch((error) => { toast.error(`Error saving profile metadata. ${error}`) + return null }) - if (signedEvent) { - if (!metadataController.validate(signedEvent)) { - toast.error(`Metadata is not valid.`) - } + if (!signedEvent) { + setSavingProfileMetadata(false) + return + } - await metadataController.publishMetadataEvent(signedEvent) + const ndkEvent = new NDKEvent(ndk, signedEvent) + const publishedOnRelays = await publish(ndkEvent) + + // Handle cases where publishing failed or succeeded + if (publishedOnRelays.length === 0) { + toast.error('Failed to publish event on any relay') + } else { + toast.success( + `Event published successfully to the following relays\n\n${publishedOnRelays.join( + '\n' + )}` + ) dispatch(setMetadataEvent(signedEvent)) } @@ -241,7 +225,7 @@ export const ProfileSettingsPage = () => { const robotAvatarLink = getRoboHashPicture(npub!, robotSet.current) - setProfileMetadata((prev) => ({ + setUserProfile((prev) => ({ ...prev, picture: robotAvatarLink })) @@ -267,14 +251,14 @@ export const ProfileSettingsPage = () => { * * @returns robohash image url */ - const getProfileImage = (metadata: ProfileMetadata) => { + const getProfileImage = (profile: NDKUserProfile) => { if (!isUsersOwnProfile) { - return metadata.picture || getRoboHashPicture(npub!) + return profile.image || getRoboHashPicture(npub!) } // userRobotImage is used only when visiting own profile // while kind 0 picture is not set - return metadata.picture || userRobotImage || getRoboHashPicture(npub!) + return profile.image || userRobotImage || getRoboHashPicture(npub!) } return ( @@ -300,7 +284,7 @@ export const ProfileSettingsPage = () => { } > - {profileMetadata && ( + {userProfile && (
{ flexDirection: 'column' }} > - {profileMetadata.banner ? ( + {userProfile.banner ? ( Banner Image ) : ( @@ -334,24 +318,9 @@ export const ProfileSettingsPage = () => { event.currentTarget.src = getRoboHashPicture(npub!) }} className={styles.img} - src={getProfileImage(profileMetadata)} + src={getProfileImage(userProfile)} alt="Profile Image" /> - - {nostrJoiningBlock && ( - - On nostr since {nostrJoiningBlock.block.toLocaleString()} - - )} {editItem('picture', 'Picture URL', undefined, undefined, { @@ -368,6 +337,7 @@ export const ProfileSettingsPage = () => { <> {usersPubkey && copyItem(nip19.npubEncode(usersPubkey), 'Public Key')} + {loginMethod === LoginMethod.privateKey && keys && keys.private &&