diff --git a/src/components/ProfileSection.tsx b/src/components/ProfileSection.tsx index 0b15bad..8277205 100644 --- a/src/components/ProfileSection.tsx +++ b/src/components/ProfileSection.tsx @@ -1,8 +1,66 @@ +import { Filter, kinds, nip19, UnsignedEvent, Event } from 'nostr-tools' +import { QRCodeSVG } from 'qrcode.react' +import { useCallback, useState } from 'react' +import { toast } from 'react-toastify' +import { + MetadataController, + RelayController, + UserRelaysType, + ZapController +} from '../controllers' +import { useAppSelector, useDidMount } from '../hooks' import '../styles/author.css' import '../styles/innerPage.css' import '../styles/socialPosts.css' +import { PaymentRequest, UserProfile } from '../types' +import { + copyTextToClipboard, + formatNumber, + log, + LogType, + unformatNumber, + now +} from '../utils' +import { LoadingSpinner } from './LoadingSpinner' +import { ZapButtons, ZapPresets, ZapQR } from './Zap' +import { getProfilePageRoute } from '../routes' +import { useNavigate } from 'react-router-dom' + +type Props = { + pubkey: string +} + +export const ProfileSection = ({ pubkey }: Props) => { + const navigate = useNavigate() + const [profile, setProfile] = useState() + + useDidMount(async () => { + const metadataController = await MetadataController.getInstance() + metadataController.findMetadata(pubkey).then((res) => { + setProfile(res) + }) + }) + + const handleCopy = async () => { + copyTextToClipboard(profile?.npub as string).then((isCopied) => { + if (isCopied) { + toast.success('Npub copied to clipboard!') + } else { + toast.error( + 'Failed to copy, look into console for more details on error!' + ) + } + }) + } + + if (!profile) return null + + const profileRoute = getProfilePageRoute( + nip19.nprofileEncode({ + pubkey + }) + ) -export const ProfileSection = () => { return (
@@ -12,7 +70,11 @@ export const ProfileSection = () => {
{ + e.preventDefault() + navigate(profileRoute) + }} >
@@ -29,10 +91,10 @@ export const ProfileSection = () => {

- {author.name} + {profile.displayName || profile.name || ''}

- {author.handle} + {profile.nip05 || ''}

@@ -44,13 +106,14 @@ export const ProfileSection = () => { id='SiteOwnerAddress' className='IBMSMSMSSS_Author_Top_Address' > - {author.address} + {profile.npub}

{ fill='currentColor' className='IBMSMSMSSS_Author_Top_Icon' > - - -
-
- - - -
-
- - +
+ +
-

{author.bio}

+

{profile.bio}

{ >
- +
@@ -154,20 +191,6 @@ export const ProfileSection = () => { ) } -interface Author { - name: string - handle: string - address: string - bio: string -} - -const author: Author = { - name: 'User name', - handle: 'nip5handle@domain.com', - address: 'npub1address', - bio: `user bio, this is a long string of temporary text that would be replaced with the user bio from their metada address` -} - interface Post { name: string link: string @@ -193,3 +216,495 @@ const posts: Post[] = [ imageUrl: '/assets/img/DEGMods%20Placeholder%20Img.png' } ] + +type QRButtonWithPopUpProps = { + pubkey: string +} + +const QRButtonWithPopUp = ({ pubkey }: QRButtonWithPopUpProps) => { + const [isOpen, setIsOpen] = useState(false) + + const nprofile = nip19.nprofileEncode({ + pubkey + }) + + const onQrCodeClicked = async () => { + const href = `https://njump.me/${nprofile}` + const a = document.createElement('a') + a.href = href + a.target = '_blank' // Open in a new tab + a.rel = 'noopener noreferrer' // Recommended for security reasons + a.click() + } + + return ( + <> +
setIsOpen(true)} + > + + + +
+ + {isOpen && ( +
+
+
+
+
+
+

Nostr Address

+
+
setIsOpen(false)} + > + + + +
+
+
+ +
+
+
+
+
+ )} + + ) +} + +type ZapButtonWithPopUpProps = { + pubkey: string +} + +const ZapButtonWithPopUp = ({ pubkey }: ZapButtonWithPopUpProps) => { + const [isOpen, setIsOpen] = useState(false) + const [amount, setAmount] = useState(0) + const [message, setMessage] = useState('') + + const [isLoading, setIsLoading] = useState(false) + const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') + + const [paymentRequest, setPaymentRequest] = useState() + + const userState = useAppSelector((state) => state.user) + + const handleClose = useCallback(() => { + setPaymentRequest(undefined) + setIsLoading(false) + setIsOpen(false) + }, []) + + const handleQRExpiry = useCallback(() => { + setPaymentRequest(undefined) + }, []) + + const handleAmountChange = (event: React.ChangeEvent) => { + const unformattedValue = unformatNumber(event.target.value) + setAmount(unformattedValue) + } + + const generatePaymentRequest = + useCallback(async (): Promise => { + let userHexKey: string + + setIsLoading(true) + setLoadingSpinnerDesc('Getting user pubkey') + + if (userState.auth && userState.user?.pubkey) { + userHexKey = userState.user.pubkey as string + } else { + userHexKey = (await window.nostr?.getPublicKey()) as string + } + + if (!userHexKey) { + setIsLoading(false) + toast.error('Could not get pubkey') + return null + } + + setLoadingSpinnerDesc('Getting admin metadata') + const metadataController = await MetadataController.getInstance() + + const authorMetadata = await metadataController.findMetadata(pubkey) + + if (!authorMetadata?.lud16) { + setIsLoading(false) + toast.error('Lighting address (lud16) is missing in admin metadata!') + return null + } + + if (!authorMetadata?.pubkey) { + setIsLoading(false) + toast.error('pubkey is missing in admin metadata!') + return null + } + + const zapController = ZapController.getInstance() + + setLoadingSpinnerDesc('Creating zap request') + return await zapController + .getLightningPaymentRequest( + authorMetadata.lud16, + amount, + authorMetadata.pubkey as string, + userHexKey, + message + ) + .catch((err) => { + toast.error(err.message || err) + return null + }) + .finally(() => { + setIsLoading(false) + }) + }, [amount, message, userState, pubkey]) + + const handleSend = useCallback(async () => { + const pr = await generatePaymentRequest() + + if (!pr) return + + setIsLoading(true) + setLoadingSpinnerDesc('Sending payment!') + + const zapController = ZapController.getInstance() + + if (await zapController.isWeblnProviderExists()) { + await zapController + .sendPayment(pr.pr) + .then(() => { + toast.success(`Successfully sent ${amount} sats!`) + handleClose() + }) + .catch((err) => { + toast.error(err.message || err) + }) + } else { + toast.warn('Webln is not present. Use QR code to send zap.') + setPaymentRequest(pr) + } + + setIsLoading(false) + }, [amount, handleClose, generatePaymentRequest]) + + const handleGenerateQRCode = async () => { + const pr = await generatePaymentRequest() + + if (!pr) return + + setPaymentRequest(pr) + } + + return ( + <> +
setIsOpen(true)} + > + + + +
+ {isOpen && ( +
+
+
+
+
+
+

Tip/Zap

+
+
+ + + +
+
+
+
+
+
+ + +
+
+ +
+
+
+ + setMessage(e.target.value)} + /> +
+ + {paymentRequest && ( + + )} +
+
+
+
+
+
+ )} + {isLoading && } + + ) +} + +type FollowButtonProps = { + pubkey: string +} + +const FollowButton = ({ pubkey }: FollowButtonProps) => { + const [isFollowing, setIsFollowing] = useState(false) + + const [isLoading, setIsLoading] = useState(false) + const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') + + const userState = useAppSelector((state) => state.user) + + useDidMount(async () => { + if (userState.auth && userState.user?.pubkey) { + const userHexKey = userState.user.pubkey as string + const { isFollowing: isAlreadyFollowing } = await checkIfFollowing( + userHexKey, + pubkey + ) + setIsFollowing(isAlreadyFollowing) + } + }) + + const getUserPubKey = async (): Promise => { + if (userState.auth && userState.user?.pubkey) { + return userState.user.pubkey as string + } else { + return (await window.nostr?.getPublicKey()) as string + } + } + + const checkIfFollowing = async ( + userHexKey: string, + pubkey: string + ): Promise<{ + isFollowing: boolean + tags: string[][] + }> => { + const filter: Filter = { + kinds: [kinds.Contacts], + authors: [userHexKey] + } + + const contactListEvent = + await RelayController.getInstance().fetchEventFromUserRelays( + filter, + userHexKey, + UserRelaysType.Both + ) + + if (!contactListEvent) + return { + isFollowing: false, + tags: [] + } + + return { + isFollowing: contactListEvent.tags.some( + (t) => t[0] === 'p' && t[1] === pubkey + ), + tags: contactListEvent.tags + } + } + + const signAndPublishEvent = async ( + unsignedEvent: UnsignedEvent + ): Promise => { + const signedEvent = await window.nostr + ?.signEvent(unsignedEvent) + .then((event) => event as Event) + .catch((err) => { + toast.error('Failed to sign the event!') + log(true, LogType.Error, 'Failed to sign the event!', err) + return null + }) + + if (!signedEvent) return false + + const publishedOnRelays = await RelayController.getInstance().publish( + signedEvent as Event + ) + + if (publishedOnRelays.length === 0) { + toast.error('Failed to publish event on any relay') + return false + } + + toast.success( + `Event published successfully to the following relays\n\n${publishedOnRelays.join( + '\n' + )}` + ) + return true + } + + const handleFollow = async () => { + setIsLoading(true) + setLoadingSpinnerDesc('Processing follow request') + + const userHexKey = await getUserPubKey() + if (!userHexKey) { + setIsLoading(false) + toast.error('Could not get pubkey') + return + } + + const { isFollowing: isAlreadyFollowing, tags } = await checkIfFollowing( + userHexKey, + pubkey + ) + if (isAlreadyFollowing) { + toast.info('Already following!') + setIsFollowing(true) + setIsLoading(false) + return + } + + const unsignedEvent: UnsignedEvent = { + content: '', + created_at: now(), + kind: kinds.Contacts, + pubkey: userHexKey, + tags: [...tags, ['p', pubkey]] + } + + setLoadingSpinnerDesc('Signing and publishing follow event') + const success = await signAndPublishEvent(unsignedEvent) + setIsFollowing(success) + setIsLoading(false) + } + + const handleUnFollow = async () => { + setIsLoading(true) + setLoadingSpinnerDesc('Processing unfollow request') + + const userHexKey = await getUserPubKey() + if (!userHexKey) { + setIsLoading(false) + toast.error('Could not get pubkey') + return + } + + const filter: Filter = { + kinds: [kinds.Contacts], + authors: [userHexKey] + } + + const contactListEvent = + await RelayController.getInstance().fetchEventFromUserRelays( + filter, + userHexKey, + UserRelaysType.Both + ) + + if ( + !contactListEvent || + !contactListEvent.tags.some((t) => t[0] === 'p' && t[1] === pubkey) + ) { + // could not found target pubkey in user's follow list + // so, just update the status and return + setIsFollowing(false) + setIsLoading(false) + return + } + + const unsignedEvent: UnsignedEvent = { + content: '', + created_at: now(), + kind: kinds.Contacts, + pubkey: userHexKey, + tags: contactListEvent.tags.filter( + (t) => !(t[0] === 'p' && t[1] === pubkey) + ) + } + + setLoadingSpinnerDesc('Signing and publishing unfollow event') + const success = await signAndPublishEvent(unsignedEvent) + setIsFollowing(!success) + setIsLoading(false) + } + + return ( + <> + + {isLoading && } + + ) +} diff --git a/src/pages/innerMod.tsx b/src/pages/innerMod.tsx index 9df1856..797e439 100644 --- a/src/pages/innerMod.tsx +++ b/src/pages/innerMod.tsx @@ -199,7 +199,7 @@ export const InnerModPage = () => {
- + diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx new file mode 100644 index 0000000..3ecd47a --- /dev/null +++ b/src/pages/profile.tsx @@ -0,0 +1,3 @@ +export const ProfilePage = () => { + return

WIP

+} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 46d6ea7..7efc2b6 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -4,6 +4,7 @@ import { GamesPage } from '../pages/games' import { HomePage } from '../pages/home' import { InnerModPage } from '../pages/innerMod' import { ModsPage } from '../pages/mods' +import { ProfilePage } from '../pages/profile' import { SettingsPage } from '../pages/settings' import { SubmitModPage } from '../pages/submitMod' import { WritePage } from '../pages/write' @@ -22,7 +23,8 @@ export const appRoutes = { settingsProfile: '/settings-profile', settingsRelays: '/settings-relays', settingsPreferences: '/settings-preferences', - settingsAdmin: '/settings-admin' + settingsAdmin: '/settings-admin', + profile: '/profile/:nprofile' } export const getModsInnerPageRoute = (eventId: string) => @@ -31,6 +33,9 @@ export const getModsInnerPageRoute = (eventId: string) => export const getModsEditPageRoute = (eventId: string) => appRoutes.editMod.replace(':naddr', eventId) +export const getProfilePageRoute = (nprofile: string) => + appRoutes.profile.replace(':nprofile', nprofile) + export const routes = [ { path: appRoutes.index, @@ -87,5 +92,9 @@ export const routes = [ { path: appRoutes.settingsAdmin, element: + }, + { + path: appRoutes.profile, + element: } ]