diff --git a/src/components/ZapThreadsWrapper.js b/src/components/ZapThreadsWrapper.js index 31d6261..05fb797 100644 --- a/src/components/ZapThreadsWrapper.js +++ b/src/components/ZapThreadsWrapper.js @@ -1,10 +1,18 @@ import React, { useEffect, useRef } from 'react'; -const ZapThreadsWrapper = ({ anchor, user, relays, disable, className }) => { +const ZapThreadsWrapper = ({ anchor, user, relays, disable, className, isAuthorized }) => { // Create a ref to store the reference to the <div> element const zapRef = useRef(null); useEffect(() => { + // Only load the script if the user is authorized + if (!isAuthorized) { + return; + } + + // Store the current value of zapRef to use in the cleanup function + const currentZapRef = zapRef.current; + // Create a new <script> element const script = document.createElement('script'); // Set the source URL of the script to load the ZapThreads library @@ -17,39 +25,67 @@ const ZapThreadsWrapper = ({ anchor, user, relays, disable, className }) => { // Create a new <zap-threads> element const zapElement = document.createElement('zap-threads'); zapElement.setAttribute('anchor', anchor); - if (user) zapElement.setAttribute('user', user); - zapElement.setAttribute('relays', relays.replace(/\s/g, '')); - if (disable) zapElement.setAttribute('disable', disable); - // Remove any existing <zap-threads> element before appending a new one - if (zapRef.current && zapRef.current.firstChild) { - zapRef.current.removeChild(zapRef.current.firstChild); + // Only set user if it exists and is not null + if (user) { + zapElement.setAttribute('user', user); } - // Append the new <zap-threads> element to the <div> referenced by zapRef - if (zapRef.current) { - zapRef.current.appendChild(zapElement); + // Clean up relay URLs + const cleanRelays = relays + .split(',') + .map(relay => relay.trim()) + .filter(relay => relay) + .join(','); + zapElement.setAttribute('relays', cleanRelays); + + // Always set disable attribute, even if empty + zapElement.setAttribute('disable', disable || ''); + + // Add error handling + zapElement.addEventListener('error', e => { + console.error('ZapThreads error:', e); + }); + + // Remove any existing <zap-threads> element + if (currentZapRef) { + while (currentZapRef.firstChild) { + currentZapRef.removeChild(currentZapRef.firstChild); + } + } + + // Append the new element + if (currentZapRef) { + currentZapRef.appendChild(zapElement); } }; // Attach the handleScriptLoad function to the script's load event script.addEventListener('load', handleScriptLoad); + script.addEventListener('error', e => { + console.error('Failed to load ZapThreads script:', e); + }); // Append the <script> element to the <body> of the document document.body.appendChild(script); // Cleanup function to remove the <zap-threads> element and the <script> element when the component is unmounted return () => { - // Remove the <zap-threads> element from the <div> referenced by zapRef - if (zapRef.current && zapRef.current.firstChild) { - zapRef.current.removeChild(zapRef.current.firstChild); + if (currentZapRef) { + while (currentZapRef.firstChild) { + currentZapRef.removeChild(currentZapRef.firstChild); + } } // Remove the <script> element from the <body> of the document document.body.removeChild(script); // Remove the load event listener from the script script.removeEventListener('load', handleScriptLoad); }; - }, [anchor, user, relays, disable]); + }, [anchor, user, relays, disable, isAuthorized]); + + if (!isAuthorized) { + return null; + } // Render a <div> element and attach the zapRef to it return <div className={`overflow-x-hidden ${className || ''}`} ref={zapRef} />; diff --git a/src/components/content/carousels/templates/CourseTemplate.js b/src/components/content/carousels/templates/CourseTemplate.js index 5b0f051..7904048 100644 --- a/src/components/content/carousels/templates/CourseTemplate.js +++ b/src/components/content/carousels/templates/CourseTemplate.js @@ -24,7 +24,9 @@ import appConfig from '@/config/appConfig'; import { BookOpen } from 'lucide-react'; export function CourseTemplate({ course, showMetaTags = true }) { - const { zaps, zapsLoading, zapsError } = useZapsSubscription({ event: course }); + const { zaps, zapsLoading, zapsError } = useZapsSubscription({ + event: course, + }); const [zapAmount, setZapAmount] = useState(0); const [lessonCount, setLessonCount] = useState(0); const [nAddress, setNAddress] = useState(null); @@ -108,7 +110,9 @@ export function CourseTemplate({ course, showMetaTags = true }) { </div> </CardHeader> <CardContent - className={`${isMobile ? 'px-3' : ''} pt-4 pb-2 w-full flex flex-row justify-between items-center`} + className={`${ + isMobile ? 'px-3' : '' + } pt-4 pb-2 w-full flex flex-row justify-between items-center`} > <div className="flex flex-wrap gap-2 max-w-[65%]"> {course && @@ -139,7 +143,9 @@ export function CourseTemplate({ course, showMetaTags = true }) { )} </CardContent> <CardDescription - className={`${isMobile ? 'w-full p-3' : 'p-6'} py-2 pt-0 text-base text-neutral-50/90 dark:text-neutral-900/90 overflow-hidden min-h-[4em] flex items-center max-w-[100%]`} + className={`${ + isMobile ? 'w-full p-3' : 'p-6' + } py-2 pt-0 text-base text-neutral-50/90 dark:text-neutral-900/90 overflow-hidden min-h-[4em] flex items-center max-w-[100%]`} style={{ overflow: 'hidden', display: '-webkit-box', @@ -156,7 +162,9 @@ export function CourseTemplate({ course, showMetaTags = true }) { </p> </CardDescription> <CardFooter - className={`flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-t border-gray-700 pt-4 ${isMobile ? 'px-3' : ''}`} + className={`flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-t border-gray-700 pt-4 ${ + isMobile ? 'px-3' : '' + }`} > <p className="text-sm text-gray-300"> {course?.published_at && course.published_at !== '' diff --git a/src/components/content/combined/CombinedDetails.js b/src/components/content/combined/CombinedDetails.js index e254b7e..ab95db1 100644 --- a/src/components/content/combined/CombinedDetails.js +++ b/src/components/content/combined/CombinedDetails.js @@ -15,8 +15,13 @@ import useWindowWidth from '@/hooks/useWindowWidth'; import dynamic from 'next/dynamic'; import { Toast } from 'primereact/toast'; import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu'; +import ZapThreadsWrapper from '@/components/ZapThreadsWrapper'; +import appConfig from '@/config/appConfig'; +import { nip19 } from 'nostr-tools'; -const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), { ssr: false }); +const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), { + ssr: false, +}); const CombinedDetails = ({ processedEvent, @@ -45,6 +50,8 @@ const CombinedDetails = ({ const isMobileView = windowWidth <= 768; const menuRef = useRef(null); const toastRef = useRef(null); + const [nsec, setNsec] = useState(null); + const [npub, setNpub] = useState(null); const handleDelete = async () => { try { @@ -126,6 +133,24 @@ const CombinedDetails = ({ } }, [zaps, processedEvent]); + useEffect(() => { + // reset first to avoid key‑leak across session changes + setNsec(null); + setNpub(null); + + if (session?.user?.privkey) { + const privkeyBuffer = Buffer.from(session.user.privkey, 'hex'); + setNsec(nip19.nsecEncode(privkeyBuffer)); + setNpub(null); + } else if (session?.user?.pubkey) { + setNsec(null); + setNpub(nip19.npubEncode(session.user.pubkey)); + } else { + setNsec(null); + setNpub(null); + } + }, [session]); + const renderPaymentMessage = () => { if (session?.user?.role?.subscribed && decryptedContent) { return ( @@ -292,6 +317,26 @@ const CombinedDetails = ({ </div> </div> <div className="w-full mt-4">{renderPaymentMessage()}</div> + {nAddress && ( + <div className="mt-8"> + {!paidResource || decryptedContent || session?.user?.role?.subscribed ? ( + <ZapThreadsWrapper + anchor={nAddress} + user={session?.user ? nsec || npub : null} + relays={appConfig.defaultRelayUrls.join(',')} + disable="zaps" + isAuthorized={true} + /> + ) : ( + <div className="text-center p-4 bg-gray-800/50 rounded-lg"> + <p className="text-gray-400"> + Comments are only available to content purchasers, subscribers, and the content + creator. + </p> + </div> + )} + </div> + )} </div> {renderContent()} </div> diff --git a/src/components/content/documents/DocumentDetails.js b/src/components/content/documents/DocumentDetails.js index 6d91805..18027a8 100644 --- a/src/components/content/documents/DocumentDetails.js +++ b/src/components/content/documents/DocumentDetails.js @@ -15,6 +15,9 @@ import useWindowWidth from '@/hooks/useWindowWidth'; import dynamic from 'next/dynamic'; import { Toast } from 'primereact/toast'; import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu'; +import ZapThreadsWrapper from '@/components/ZapThreadsWrapper'; +import appConfig from '@/config/appConfig'; +import { nip19 } from 'nostr-tools'; const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), { ssr: false, @@ -40,13 +43,17 @@ const DocumentDetails = ({ const [course, setCourse] = useState(null); const router = useRouter(); const { returnImageProxy } = useImageProxy(); - const { zaps, zapsLoading, zapsError } = useZapsSubscription({ event: processedEvent }); + const { zaps, zapsLoading, zapsError } = useZapsSubscription({ + event: processedEvent, + }); const { data: session, status } = useSession(); const { showToast } = useToast(); const windowWidth = useWindowWidth(); const isMobileView = windowWidth <= 768; const menuRef = useRef(null); const toastRef = useRef(null); + const [nsec, setNsec] = useState(null); + const [npub, setNpub] = useState(null); const handleDelete = async () => { try { @@ -134,6 +141,20 @@ const DocumentDetails = ({ } }, [processedEvent.d, isLesson]); + useEffect(() => { + if (session?.user?.privkey) { + const privkeyBuffer = Buffer.from(session.user.privkey, 'hex'); + setNsec(nip19.nsecEncode(privkeyBuffer)); + setNpub(null); + } else if (session?.user?.pubkey) { + setNsec(null); + setNpub(nip19.npubEncode(session.user.pubkey)); + } else { + setNsec(null); + setNpub(null); + } + }, [session]); + const renderPaymentMessage = () => { if (session?.user && session.user?.role?.subscribed && decryptedContent) { return ( @@ -159,7 +180,9 @@ const DocumentDetails = ({ return ( <GenericButton tooltipOptions={{ position: 'top' }} - tooltip={`You have this lesson through purchasing the course it belongs to. You paid ${session?.user?.purchased?.find(purchase => purchase.courseId === course)?.course?.price} sats for the course.`} + tooltip={`You have this lesson through purchasing the course it belongs to. You paid ${ + session?.user?.purchased?.find(purchase => purchase.courseId === course)?.course?.price + } sats for the course.`} icon="pi pi-check" label={`Paid`} severity="success" diff --git a/src/components/content/videos/VideoDetails.js b/src/components/content/videos/VideoDetails.js index f9219aa..6c9625c 100644 --- a/src/components/content/videos/VideoDetails.js +++ b/src/components/content/videos/VideoDetails.js @@ -15,6 +15,10 @@ import useWindowWidth from '@/hooks/useWindowWidth'; import dynamic from 'next/dynamic'; import { Toast } from 'primereact/toast'; import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu'; +import ZapThreadsWrapper from '@/components/ZapThreadsWrapper'; +import appConfig from '@/config/appConfig'; +import { nip19 } from 'nostr-tools'; +import { Buffer } from 'buffer'; const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), { ssr: false, @@ -40,13 +44,17 @@ const VideoDetails = ({ const [course, setCourse] = useState(null); const router = useRouter(); const { returnImageProxy } = useImageProxy(); - const { zaps, zapsLoading, zapsError } = useZapsSubscription({ event: processedEvent }); + const { zaps, zapsLoading, zapsError } = useZapsSubscription({ + event: processedEvent, + }); const { data: session, status } = useSession(); const { showToast } = useToast(); const windowWidth = useWindowWidth(); const isMobileView = windowWidth <= 768; const menuRef = useRef(null); const toastRef = useRef(null); + const [nsec, setNsec] = useState(null); + const [npub, setNpub] = useState(null); const handleDelete = async () => { try { @@ -134,6 +142,20 @@ const VideoDetails = ({ } }, [zaps, processedEvent]); + useEffect(() => { + if (session?.user?.privkey) { + const privkeyBuffer = Buffer.from(session.user.privkey, 'hex'); + setNsec(nip19.nsecEncode(privkeyBuffer)); + setNpub(null); + } else if (session?.user?.pubkey) { + setNsec(null); + setNpub(nip19.npubEncode(session.user.pubkey)); + } else { + setNsec(null); + setNpub(null); + } + }, [session]); + const renderPaymentMessage = () => { if (session?.user && session.user?.role?.subscribed && decryptedContent) { return ( @@ -158,9 +180,13 @@ const VideoDetails = ({ return ( <GenericButton tooltipOptions={{ position: 'top' }} - tooltip={`You have this lesson through purchasing the course it belongs to. You paid ${session?.user?.purchased?.find(purchase => purchase.courseId === course)?.course?.price} sats for the course.`} + tooltip={`You have this lesson through purchasing the course it belongs to. You paid ${ + session?.user?.purchased?.find(purchase => purchase.courseId === course)?.course?.price + } sats for the course.`} icon="pi pi-check" - label={`Paid ${session?.user?.purchased?.find(purchase => purchase.courseId === course)?.course?.price} sats`} + label={`Paid ${ + session?.user?.purchased?.find(purchase => purchase.courseId === course)?.course?.price + } sats`} severity="success" outlined size="small" diff --git a/src/components/feeds/messages/CommunityMessage.js b/src/components/feeds/messages/CommunityMessage.js index 803ead7..61de758 100644 --- a/src/components/feeds/messages/CommunityMessage.js +++ b/src/components/feeds/messages/CommunityMessage.js @@ -68,14 +68,16 @@ const CommunityMessage = ({ message, searchQuery, windowWidth, platform }) => { const { data: session } = useSession(); useEffect(() => { - if (session?.user?.pubkey || session?.user?.privkey) { - let privkeyBuffer; - if (session.user.privkey) { - privkeyBuffer = Buffer.from(session.user.privkey, 'hex'); - setNsec(nip19.nsecEncode(privkeyBuffer)); - } else { - setNpub(nip19.npubEncode(session.user.pubkey)); - } + if (session?.user?.privkey) { + const privkeyBuffer = Buffer.from(session.user.privkey, 'hex'); + setNsec(nip19.nsecEncode(privkeyBuffer)); + setNpub(null); + } else if (session?.user?.pubkey) { + setNsec(null); + setNpub(nip19.npubEncode(session.user.pubkey)); + } else { + setNsec(null); + setNpub(null); } }, [session]); diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index 3e09182..a99ef5e 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -15,8 +15,12 @@ import { Accordion, AccordionTab } from 'primereact/accordion'; import { Tag } from 'primereact/tag'; import { useDecryptContent } from '@/hooks/encryption/useDecryptContent'; import dynamic from 'next/dynamic'; +import ZapThreadsWrapper from '@/components/ZapThreadsWrapper'; +import appConfig from '@/config/appConfig'; -const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), { ssr: false }); +const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), { + ssr: false, +}); const useCourseData = (ndk, fetchAuthor, router) => { const [course, setCourse] = useState(null); @@ -94,7 +98,11 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => { const fetchLesson = async lessonId => { try { await ndk.connect(); - const filter = { '#d': [lessonId], kinds: [30023, 30402], authors: [pubkey] }; + const filter = { + '#d': [lessonId], + kinds: [30023, 30402], + authors: [pubkey], + }; const event = await ndk.fetchEvent(filter); if (event) { const author = await fetchAuthor(event.pubkey); @@ -171,6 +179,9 @@ const Course = () => { const { showToast } = useToast(); const [expandedIndex, setExpandedIndex] = useState(null); const [completedLessons, setCompletedLessons] = useState([]); + const [nAddresses, setNAddresses] = useState({}); + const [nsec, setNsec] = useState(null); + const [npub, setNpub] = useState(null); const setCompleted = useCallback(lessonId => { setCompletedLessons(prev => [...prev, lessonId]); @@ -217,6 +228,36 @@ const Course = () => { } }, [router.isReady, router.query]); + useEffect(() => { + if (uniqueLessons.length > 0) { + const addresses = {}; + uniqueLessons.forEach(lesson => { + const addr = nip19.naddrEncode({ + pubkey: lesson.pubkey, + kind: lesson.kind, + identifier: lesson.d, + relays: appConfig.defaultRelayUrls, + }); + addresses[lesson.id] = addr; + }); + setNAddresses(addresses); + } + }, [uniqueLessons]); + + useEffect(() => { + if (session?.user?.privkey) { + const privkeyBuffer = Buffer.from(session.user.privkey, 'hex'); + setNsec(nip19.nsecEncode(privkeyBuffer)); + setNpub(null); + } else if (session?.user?.pubkey) { + setNsec(null); + setNpub(nip19.npubEncode(session.user.pubkey)); + } else { + setNsec(null); + setNpub(null); + } + }, [session]); + const handleAccordionChange = e => { const newIndex = e.index === expandedIndex ? null : e.index; setExpandedIndex(newIndex); @@ -327,7 +368,29 @@ const Course = () => { </div> } > - <div className="w-full py-4 rounded-b-lg">{renderLesson(lesson)}</div> + <div className="w-full py-4 rounded-b-lg"> + {renderLesson(lesson)} + {nAddresses[lesson.id] && ( + <div className="mt-8"> + {!paidCourse || decryptionPerformed || session?.user?.role?.subscribed ? ( + <ZapThreadsWrapper + anchor={nAddresses[lesson.id]} + user={session?.user ? nsec || npub : null} + relays={appConfig.defaultRelayUrls.join(',')} + disable="zaps" + isAuthorized={true} + /> + ) : ( + <div className="text-center p-4 bg-gray-800/50 rounded-lg"> + <p className="text-gray-400"> + Comments are only available to course purchasers, subscribers, and the + course creator. + </p> + </div> + )} + </div> + )} + </div> </AccordionTab> ))} </Accordion> diff --git a/src/pages/details/[slug]/index.js b/src/pages/details/[slug]/index.js index fb87e7c..c4704d8 100644 --- a/src/pages/details/[slug]/index.js +++ b/src/pages/details/[slug]/index.js @@ -83,8 +83,13 @@ const Details = () => { if (session?.user?.privkey) { const privkeyBuffer = Buffer.from(session.user.privkey, 'hex'); setNsec(nip19.nsecEncode(privkeyBuffer)); + setNpub(null); } else if (session?.user?.pubkey) { + setNsec(null); setNpub(nip19.npubEncode(session.user.pubkey)); + } else { + setNsec(null); + setNpub(null); } }, [session]); @@ -190,6 +195,9 @@ const Details = () => { const DetailComponent = getDetailComponent(); + const isAuthorized = + !event.price || decryptedContent || session?.user?.role?.subscribed || authorView; + return ( <> <DetailComponent @@ -208,25 +216,23 @@ const Details = () => { handlePaymentError={handlePaymentError} authorView={authorView} /> - {nAddress !== null && (nsec || npub) ? ( + {nAddress !== null && isAuthorized ? ( <div className="px-4"> <ZapThreadsWrapper anchor={nAddress} user={nsec || npub || null} relays="wss://nos.lol/, wss://relay.damus.io/, wss://relay.snort.social/, wss://relay.nostr.band/, wss://relay.primal.net/, wss://nostrue.com/, wss://purplerelay.com/, wss://relay.devs.tools/" disable="zaps" + isAuthorized={isAuthorized} /> </div> - ) : nAddress !== null ? ( - <div className="px-4"> - <ZapThreadsWrapper - anchor={nAddress} - user={npub} - relays="wss://nos.lol/, wss://relay.damus.io/, wss://relay.snort.social/, wss://relay.nostr.band/, wss://relay.primal.net/, wss://nostrue.com/, wss://purplerelay.com/, wss://relay.devs.tools/" - disable="zaps" - /> + ) : ( + <div className="text-center p-4 mx-4 bg-gray-800/50 rounded-lg"> + <p className="text-gray-400"> + Comments are only available to content purchasers, subscribers, and the content creator. + </p> </div> - ) : null} + )} </> ); }; diff --git a/src/styles/globals.css b/src/styles/globals.css index 9a45c40..30d3ed8 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -148,4 +148,32 @@ code { /* hide attribution */ div.react-flow__attribution { display: none !important; +} + +/* Dialog backdrop styles */ +.p-dialog-mask { + backdrop-filter: blur(8px); + background-color: rgba(0, 0, 0, 0.5); +} + +.p-dialog { + background: #1e1e1e; + border: 1px solid #333; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.p-dialog-header { + background: #1e1e1e; + border-bottom: 1px solid #333; + color: #fff; +} + +.p-dialog-content { + background: #1e1e1e; + color: #fff; +} + +.p-dialog-footer { + background: #1e1e1e; + border-top: 1px solid #333; } \ No newline at end of file