From 07e94fbb40861ba4478529cd6360ef41e77842cc Mon Sep 17 00:00:00 2001 From: kiwihodl Date: Wed, 2 Apr 2025 16:38:37 -0500 Subject: [PATCH] feat: implement ZapThreads comments for all content types (video, document, combined) and course pages --- .../content/combined/CombinedDetails.js | 173 ++++++++++----- .../content/documents/DocumentDetails.js | 178 +++++++++++----- src/components/content/videos/VideoDetails.js | 196 +++++++++++------ src/pages/course/[slug]/index.js | 201 +++++++++++++----- 4 files changed, 523 insertions(+), 225 deletions(-) diff --git a/src/components/content/combined/CombinedDetails.js b/src/components/content/combined/CombinedDetails.js index e254b7e..102d3d3 100644 --- a/src/components/content/combined/CombinedDetails.js +++ b/src/components/content/combined/CombinedDetails.js @@ -1,22 +1,27 @@ -import React, { useEffect, useState, useRef } from 'react'; -import axios from 'axios'; -import { useToast } from '@/hooks/useToast'; -import { Tag } from 'primereact/tag'; -import Image from 'next/image'; -import { useRouter } from 'next/router'; -import ResourcePaymentButton from '@/components/bitcoinConnect/ResourcePaymentButton'; -import ZapDisplay from '@/components/zaps/ZapDisplay'; -import GenericButton from '@/components/buttons/GenericButton'; -import { useImageProxy } from '@/hooks/useImageProxy'; -import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscription'; -import { getTotalFromZaps } from '@/utils/lightning'; -import { useSession } from 'next-auth/react'; -import useWindowWidth from '@/hooks/useWindowWidth'; -import dynamic from 'next/dynamic'; -import { Toast } from 'primereact/toast'; -import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu'; +import React, { useEffect, useState, useRef } from "react"; +import axios from "axios"; +import { useToast } from "@/hooks/useToast"; +import { Tag } from "primereact/tag"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import ResourcePaymentButton from "@/components/bitcoinConnect/ResourcePaymentButton"; +import ZapDisplay from "@/components/zaps/ZapDisplay"; +import GenericButton from "@/components/buttons/GenericButton"; +import { useImageProxy } from "@/hooks/useImageProxy"; +import { useZapsSubscription } from "@/hooks/nostrQueries/zaps/useZapsSubscription"; +import { getTotalFromZaps } from "@/utils/lightning"; +import { useSession } from "next-auth/react"; +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,62 +50,72 @@ 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 { const response = await axios.delete(`/api/resources/${processedEvent.d}`); if (response.status === 204) { - showToast('success', 'Success', 'Resource deleted successfully.'); - router.push('/'); + showToast("success", "Success", "Resource deleted successfully."); + router.push("/"); } } catch (error) { - if (error.response?.data?.error?.includes('Invalid `prisma.resource.delete()`')) { + if ( + error.response?.data?.error?.includes( + "Invalid `prisma.resource.delete()`" + ) + ) { showToast( - 'error', - 'Error', - 'Resource cannot be deleted because it is part of a course, delete the course first.' + "error", + "Error", + "Resource cannot be deleted because it is part of a course, delete the course first." ); } else { - showToast('error', 'Error', 'Failed to delete resource. Please try again.'); + showToast( + "error", + "Error", + "Failed to delete resource. Please try again." + ); } } }; const authorMenuItems = [ { - label: 'Edit', - icon: 'pi pi-pencil', + label: "Edit", + icon: "pi pi-pencil", command: () => router.push(`/details/${processedEvent.id}/edit`), }, { - label: 'Delete', - icon: 'pi pi-trash', + label: "Delete", + icon: "pi pi-trash", command: handleDelete, }, { - label: 'View Nostr note', - icon: 'pi pi-globe', + label: "View Nostr note", + icon: "pi pi-globe", command: () => { - window.open(`https://habla.news/a/${nAddress}`, '_blank'); + window.open(`https://habla.news/a/${nAddress}`, "_blank"); }, }, ]; const userMenuItems = [ { - label: 'View Nostr note', - icon: 'pi pi-globe', + label: "View Nostr note", + icon: "pi pi-globe", command: () => { - window.open(`https://habla.news/a/${nAddress}`, '_blank'); + window.open(`https://habla.news/a/${nAddress}`, "_blank"); }, }, ]; if (course) { userMenuItems.unshift({ - label: isMobileView ? 'Course' : 'Open Course', - icon: 'pi pi-external-link', - command: () => window.open(`/course/${course}`, '_blank'), + label: isMobileView ? "Course" : "Open Course", + icon: "pi pi-external-link", + command: () => window.open(`/course/${course}`, "_blank"), }); } @@ -108,13 +123,13 @@ const CombinedDetails = ({ if (isLesson) { axios .get(`/api/resources/${processedEvent.d}`) - .then(res => { + .then((res) => { if (res.data && res.data.lessons[0]?.courseId) { setCourse(res.data.lessons[0]?.courseId); } }) - .catch(err => { - console.error('err', err); + .catch((err) => { + console.error("err", err); }); } }, [processedEvent.d, isLesson]); @@ -126,11 +141,20 @@ const CombinedDetails = ({ } }, [zaps, processedEvent]); + useEffect(() => { + if (session?.user?.privkey) { + const privkeyBuffer = Buffer.from(session.user.privkey, "hex"); + setNsec(nip19.nsecEncode(privkeyBuffer)); + } else if (session?.user?.pubkey) { + setNpub(nip19.npubEncode(session.user.pubkey)); + } + }, [session]); + const renderPaymentMessage = () => { if (session?.user?.role?.subscribed && decryptedContent) { return ( purchase.courseId === course) + session?.user?.purchased?.some((purchase) => purchase.courseId === course) ) { const coursePurchase = session?.user?.purchased?.find( - purchase => purchase.courseId === course + (purchase) => purchase.courseId === course ); return ( { if (decryptedContent) { - return ; + return ( + + ); } if (paidResource && !decryptedContent) { @@ -231,7 +264,12 @@ const CombinedDetails = ({ } if (processedEvent?.content) { - return ; + return ( + + ); } return null; @@ -241,7 +279,12 @@ const CombinedDetails = ({
- background image + background image
@@ -258,9 +301,11 @@ const CombinedDetails = ({ {topics?.map((topic, index) => ( ))} - {isLesson && } + {isLesson && ( + + )}
- {summary?.split('\n').map((line, index) => ( + {summary?.split("\n").map((line, index) => (

{line}

))}
@@ -273,7 +318,7 @@ const CombinedDetails = ({ className="rounded-full mr-4" />

- By{' '} + By{" "}

{renderPaymentMessage()}
+ {nAddress && ( +
+ {!paidResource || + decryptedContent || + session?.user?.role?.subscribed ? ( + + ) : ( +
+

+ Comments are only available to content purchasers, + subscribers, and the content creator. +

+
+ )} +
+ )}
{renderContent()} diff --git a/src/components/content/documents/DocumentDetails.js b/src/components/content/documents/DocumentDetails.js index 6d91805..8164aea 100644 --- a/src/components/content/documents/DocumentDetails.js +++ b/src/components/content/documents/DocumentDetails.js @@ -1,22 +1,25 @@ -import React, { useEffect, useState, useRef } from 'react'; -import axios from 'axios'; -import { useToast } from '@/hooks/useToast'; -import { Tag } from 'primereact/tag'; -import Image from 'next/image'; -import { useRouter } from 'next/router'; -import ResourcePaymentButton from '@/components/bitcoinConnect/ResourcePaymentButton'; -import ZapDisplay from '@/components/zaps/ZapDisplay'; -import GenericButton from '@/components/buttons/GenericButton'; -import { useImageProxy } from '@/hooks/useImageProxy'; -import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscription'; -import { getTotalFromZaps } from '@/utils/lightning'; -import { useSession } from 'next-auth/react'; -import useWindowWidth from '@/hooks/useWindowWidth'; -import dynamic from 'next/dynamic'; -import { Toast } from 'primereact/toast'; -import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu'; +import React, { useEffect, useState, useRef } from "react"; +import axios from "axios"; +import { useToast } from "@/hooks/useToast"; +import { Tag } from "primereact/tag"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import ResourcePaymentButton from "@/components/bitcoinConnect/ResourcePaymentButton"; +import ZapDisplay from "@/components/zaps/ZapDisplay"; +import GenericButton from "@/components/buttons/GenericButton"; +import { useImageProxy } from "@/hooks/useImageProxy"; +import { useZapsSubscription } from "@/hooks/nostrQueries/zaps/useZapsSubscription"; +import { getTotalFromZaps } from "@/utils/lightning"; +import { useSession } from "next-auth/react"; +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'), { +const MDDisplay = dynamic(() => import("@uiw/react-markdown-preview"), { ssr: false, }); @@ -40,75 +43,87 @@ 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 { const response = await axios.delete(`/api/resources/${processedEvent.d}`); if (response.status === 204) { - showToast('success', 'Success', 'Resource deleted successfully.'); - router.push('/'); + showToast("success", "Success", "Resource deleted successfully."); + router.push("/"); } } catch (error) { if ( error.response && error.response.data && - error.response.data.error.includes('Invalid `prisma.resource.delete()`') + error.response.data.error.includes("Invalid `prisma.resource.delete()`") ) { showToast( - 'error', - 'Error', - 'Resource cannot be deleted because it is part of a course, delete the course first.' + "error", + "Error", + "Resource cannot be deleted because it is part of a course, delete the course first." ); - } else if (error.response && error.response.data && error.response.data.error) { - showToast('error', 'Error', error.response.data.error); + } else if ( + error.response && + error.response.data && + error.response.data.error + ) { + showToast("error", "Error", error.response.data.error); } else { - showToast('error', 'Error', 'Failed to delete resource. Please try again.'); + showToast( + "error", + "Error", + "Failed to delete resource. Please try again." + ); } } }; const authorMenuItems = [ { - label: 'Edit', - icon: 'pi pi-pencil', + label: "Edit", + icon: "pi pi-pencil", command: () => router.push(`/details/${processedEvent.id}/edit`), }, { - label: 'Delete', - icon: 'pi pi-trash', + label: "Delete", + icon: "pi pi-trash", command: handleDelete, }, { - label: 'View Nostr note', - icon: 'pi pi-globe', + label: "View Nostr note", + icon: "pi pi-globe", command: () => { - window.open(`https://habla.news/a/${nAddress}`, '_blank'); + window.open(`https://habla.news/a/${nAddress}`, "_blank"); }, }, ]; const userMenuItems = [ { - label: 'View Nostr note', - icon: 'pi pi-globe', + label: "View Nostr note", + icon: "pi pi-globe", command: () => { - window.open(`https://habla.news/a/${nAddress}`, '_blank'); + window.open(`https://habla.news/a/${nAddress}`, "_blank"); }, }, ]; if (course) { userMenuItems.unshift({ - label: isMobileView ? 'Course' : 'Open Course', - icon: 'pi pi-external-link', - command: () => window.open(`/course/${course}`, '_blank'), + label: isMobileView ? "Course" : "Open Course", + icon: "pi pi-external-link", + command: () => window.open(`/course/${course}`, "_blank"), }); } @@ -123,22 +138,31 @@ const DocumentDetails = ({ if (isLesson) { axios .get(`/api/resources/${processedEvent.d}`) - .then(res => { + .then((res) => { if (res.data && res.data.lessons[0]?.courseId) { setCourse(res.data.lessons[0]?.courseId); } }) - .catch(err => { - console.error('err', err); + .catch((err) => { + console.error("err", err); }); } }, [processedEvent.d, isLesson]); + useEffect(() => { + if (session?.user?.privkey) { + const privkeyBuffer = Buffer.from(session.user.privkey, "hex"); + setNsec(nip19.nsecEncode(privkeyBuffer)); + } else if (session?.user?.pubkey) { + setNpub(nip19.npubEncode(session.user.pubkey)); + } + }, [session]); + const renderPaymentMessage = () => { if (session?.user && session.user?.role?.subscribed && decryptedContent) { return ( purchase.courseId === course) + session?.user?.purchased?.some((purchase) => purchase.courseId === course) ) { return ( purchase.courseId === course)?.course?.price} sats for the course.`} + 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.`} icon="pi pi-check" label={`Paid`} severity="success" @@ -185,16 +213,20 @@ const DocumentDetails = ({ outlined size="small" tooltip={`You paid ${processedEvent.price} sats to access this content (or potentially less if a discount was applied)`} - tooltipOptions={{ position: 'top' }} + tooltipOptions={{ position: "top" }} className="cursor-default hover:opacity-100 hover:bg-transparent focus:ring-0" /> ); } - if (paidResource && author && processedEvent?.pubkey === session?.user?.pubkey) { + if ( + paidResource && + author && + processedEvent?.pubkey === session?.user?.pubkey + ) { return ( { if (decryptedContent) { - return ; + return ( + + ); } if (paidResource && !decryptedContent) { return ( @@ -237,7 +274,12 @@ const DocumentDetails = ({ ); } if (processedEvent?.content) { - return ; + return ( + + ); } return null; }; @@ -270,9 +312,11 @@ const DocumentDetails = ({ topics.map((topic, index) => ( ))} - {isLesson && } + {isLesson && ( + + )} - {summary?.split('\n').map((line, index) => ( + {summary?.split("\n").map((line, index) => (

{line}

))}
{renderPaymentMessage()}
+ {nAddress && ( +
+ {!paidResource || + decryptedContent || + session?.user?.role?.subscribed ? ( + + ) : ( +
+

+ Comments are only available to content purchasers, + subscribers, and the content creator. +

+
+ )} +
+ )} {renderContent()} diff --git a/src/components/content/videos/VideoDetails.js b/src/components/content/videos/VideoDetails.js index f9219aa..5870485 100644 --- a/src/components/content/videos/VideoDetails.js +++ b/src/components/content/videos/VideoDetails.js @@ -1,22 +1,25 @@ -import React, { useEffect, useState, useRef } from 'react'; -import axios from 'axios'; -import { useToast } from '@/hooks/useToast'; -import { Tag } from 'primereact/tag'; -import Image from 'next/image'; -import { useRouter } from 'next/router'; -import ResourcePaymentButton from '@/components/bitcoinConnect/ResourcePaymentButton'; -import ZapDisplay from '@/components/zaps/ZapDisplay'; -import GenericButton from '@/components/buttons/GenericButton'; -import { useImageProxy } from '@/hooks/useImageProxy'; -import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscription'; -import { getTotalFromZaps } from '@/utils/lightning'; -import { useSession } from 'next-auth/react'; -import useWindowWidth from '@/hooks/useWindowWidth'; -import dynamic from 'next/dynamic'; -import { Toast } from 'primereact/toast'; -import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu'; +import React, { useEffect, useState, useRef } from "react"; +import axios from "axios"; +import { useToast } from "@/hooks/useToast"; +import { Tag } from "primereact/tag"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import ResourcePaymentButton from "@/components/bitcoinConnect/ResourcePaymentButton"; +import ZapDisplay from "@/components/zaps/ZapDisplay"; +import GenericButton from "@/components/buttons/GenericButton"; +import { useImageProxy } from "@/hooks/useImageProxy"; +import { useZapsSubscription } from "@/hooks/nostrQueries/zaps/useZapsSubscription"; +import { getTotalFromZaps } from "@/utils/lightning"; +import { useSession } from "next-auth/react"; +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'), { +const MDDisplay = dynamic(() => import("@uiw/react-markdown-preview"), { ssr: false, }); @@ -40,75 +43,87 @@ 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 { const response = await axios.delete(`/api/resources/${processedEvent.d}`); if (response.status === 204) { - showToast('success', 'Success', 'Resource deleted successfully.'); - router.push('/'); + showToast("success", "Success", "Resource deleted successfully."); + router.push("/"); } } catch (error) { if ( error.response && error.response.data && - error.response.data.error.includes('Invalid `prisma.resource.delete()`') + error.response.data.error.includes("Invalid `prisma.resource.delete()`") ) { showToast( - 'error', - 'Error', - 'Resource cannot be deleted because it is part of a course, delete the course first.' + "error", + "Error", + "Resource cannot be deleted because it is part of a course, delete the course first." ); - } else if (error.response && error.response.data && error.response.data.error) { - showToast('error', 'Error', error.response.data.error); + } else if ( + error.response && + error.response.data && + error.response.data.error + ) { + showToast("error", "Error", error.response.data.error); } else { - showToast('error', 'Error', 'Failed to delete resource. Please try again.'); + showToast( + "error", + "Error", + "Failed to delete resource. Please try again." + ); } } }; const authorMenuItems = [ { - label: 'Edit', - icon: 'pi pi-pencil', + label: "Edit", + icon: "pi pi-pencil", command: () => router.push(`/details/${processedEvent.id}/edit`), }, { - label: 'Delete', - icon: 'pi pi-trash', + label: "Delete", + icon: "pi pi-trash", command: handleDelete, }, { - label: 'View Nostr note', - icon: 'pi pi-globe', + label: "View Nostr note", + icon: "pi pi-globe", command: () => { - window.open(`https://habla.news/a/${nAddress}`, '_blank'); + window.open(`https://habla.news/a/${nAddress}`, "_blank"); }, }, ]; const userMenuItems = [ { - label: 'View Nostr note', - icon: 'pi pi-globe', + label: "View Nostr note", + icon: "pi pi-globe", command: () => { - window.open(`https://habla.news/a/${nAddress}`, '_blank'); + window.open(`https://habla.news/a/${nAddress}`, "_blank"); }, }, ]; if (course) { userMenuItems.unshift({ - label: isMobileView ? 'Course' : 'Open Course', - icon: 'pi pi-external-link', - command: () => window.open(`/course/${course}`, '_blank'), + label: isMobileView ? "Course" : "Open Course", + icon: "pi pi-external-link", + command: () => window.open(`/course/${course}`, "_blank"), }); } @@ -116,13 +131,13 @@ const VideoDetails = ({ if (isLesson) { axios .get(`/api/resources/${processedEvent.d}`) - .then(res => { + .then((res) => { if (res.data && res.data.lessons[0]?.courseId) { setCourse(res.data.lessons[0]?.courseId); } }) - .catch(err => { - console.error('err', err); + .catch((err) => { + console.error("err", err); }); } }, [processedEvent.d, isLesson]); @@ -134,11 +149,20 @@ const VideoDetails = ({ } }, [zaps, processedEvent]); + useEffect(() => { + if (session?.user?.privkey) { + const privkeyBuffer = Buffer.from(session.user.privkey, "hex"); + setNsec(nip19.nsecEncode(privkeyBuffer)); + } else if (session?.user?.pubkey) { + setNpub(nip19.npubEncode(session.user.pubkey)); + } + }, [session]); + const renderPaymentMessage = () => { if (session?.user && session.user?.role?.subscribed && decryptedContent) { return ( purchase.courseId === course) + session?.user?.purchased?.some((purchase) => purchase.courseId === course) ) { return ( purchase.courseId === course)?.course?.price} sats for the course.`} + 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.`} 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" @@ -188,10 +220,14 @@ const VideoDetails = ({ ); } - if (paidResource && author && processedEvent?.pubkey === session?.user?.pubkey) { + if ( + paidResource && + author && + processedEvent?.pubkey === session?.user?.pubkey + ) { return ( { if (decryptedContent) { - return ; + return ( + + ); } if (paidResource && !decryptedContent) { return ( @@ -217,8 +258,8 @@ const VideoDetails = ({ className="absolute inset-0 opacity-50" style={{ backgroundImage: `url(${image})`, - backgroundSize: 'cover', - backgroundPosition: 'center', + backgroundSize: "cover", + backgroundPosition: "center", }} >
@@ -241,7 +282,12 @@ const VideoDetails = ({ ); } if (processedEvent?.content) { - return ; + return ( + + ); } return null; }; @@ -251,7 +297,9 @@ const VideoDetails = ({ {renderContent()}
); diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index 3e09182..5be9efd 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -1,22 +1,26 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { useRouter } from 'next/router'; -import { parseCourseEvent, parseEvent, findKind0Fields } from '@/utils/nostr'; -import CourseDetails from '@/components/content/courses/CourseDetails'; -import VideoLesson from '@/components/content/courses/VideoLesson'; -import DocumentLesson from '@/components/content/courses/DocumentLesson'; -import CombinedLesson from '@/components/content/courses/CombinedLesson'; -import { useNDKContext } from '@/context/NDKContext'; -import { useSession } from 'next-auth/react'; -import axios from 'axios'; -import { nip04, nip19 } from 'nostr-tools'; -import { useToast } from '@/hooks/useToast'; -import { ProgressSpinner } from 'primereact/progressspinner'; -import { Accordion, AccordionTab } from 'primereact/accordion'; -import { Tag } from 'primereact/tag'; -import { useDecryptContent } from '@/hooks/encryption/useDecryptContent'; -import dynamic from 'next/dynamic'; +import React, { useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/router"; +import { parseCourseEvent, parseEvent, findKind0Fields } from "@/utils/nostr"; +import CourseDetails from "@/components/content/courses/CourseDetails"; +import VideoLesson from "@/components/content/courses/VideoLesson"; +import DocumentLesson from "@/components/content/courses/DocumentLesson"; +import CombinedLesson from "@/components/content/courses/CombinedLesson"; +import { useNDKContext } from "@/context/NDKContext"; +import { useSession } from "next-auth/react"; +import axios from "axios"; +import { nip04, nip19 } from "nostr-tools"; +import { useToast } from "@/hooks/useToast"; +import { ProgressSpinner } from "primereact/progressspinner"; +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); @@ -32,10 +36,10 @@ const useCourseData = (ndk, fetchAuthor, router) => { let id; const fetchCourseId = async () => { - if (slug.includes('naddr')) { + if (slug.includes("naddr")) { const { data } = nip19.decode(slug); if (!data?.identifier) { - showToast('error', 'Error', 'Resource not found'); + showToast("error", "Error", "Resource not found"); return null; } return data.identifier; @@ -44,19 +48,21 @@ const useCourseData = (ndk, fetchAuthor, router) => { } }; - const fetchCourse = async courseId => { + const fetchCourse = async (courseId) => { try { await ndk.connect(); - const event = await ndk.fetchEvent({ '#d': [courseId] }); + const event = await ndk.fetchEvent({ "#d": [courseId] }); if (!event) return null; const author = await fetchAuthor(event.pubkey); - const lessonIds = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1].split(':')[2]); + const lessonIds = event.tags + .filter((tag) => tag[0] === "a") + .map((tag) => tag[1].split(":")[2]); const parsedCourse = { ...parseCourseEvent(event), author }; return { parsedCourse, lessonIds }; } catch (error) { - console.error('Error fetching event:', error); + console.error("Error fetching event:", error); return null; } }; @@ -91,17 +97,23 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => { const { showToast } = useToast(); useEffect(() => { if (lessonIds.length > 0) { - const fetchLesson = async lessonId => { + 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); const parsedLesson = { ...parseEvent(event), author }; - setLessons(prev => { + setLessons((prev) => { // Check if the lesson already exists in the array - const exists = prev.some(lesson => lesson.id === parsedLesson.id); + const exists = prev.some( + (lesson) => lesson.id === parsedLesson.id + ); if (!exists) { return [...prev, parsedLesson]; } @@ -109,16 +121,16 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => { }); } } catch (error) { - console.error('Error fetching event:', error); + console.error("Error fetching event:", error); } }; - lessonIds.forEach(lessonId => fetchLesson(lessonId)); + lessonIds.forEach((lessonId) => fetchLesson(lessonId)); } }, [lessonIds, ndk, fetchAuthor, pubkey]); useEffect(() => { const newUniqueLessons = Array.from( - new Map(lessons.map(lesson => [lesson.id, lesson])).values() + new Map(lessons.map((lesson) => [lesson.id, lesson])).values() ); setUniqueLessons(newUniqueLessons); }, [lessons]); @@ -136,14 +148,16 @@ const useDecryption = (session, paidCourse, course, lessons, setLessons) => { if (session?.user && paidCourse && !decryptionPerformed) { setLoading(true); const canAccess = - session.user.purchased?.some(purchase => purchase.courseId === course?.d) || + session.user.purchased?.some( + (purchase) => purchase.courseId === course?.d + ) || session.user?.role?.subscribed || session.user?.pubkey === course?.pubkey; if (canAccess && lessons.length > 0) { try { const decryptedLessons = await Promise.all( - lessons.map(async lesson => { + lessons.map(async (lesson) => { const decryptedContent = await decryptContent(lesson.content); return { ...lesson, content: decryptedContent }; }) @@ -151,7 +165,7 @@ const useDecryption = (session, paidCourse, course, lessons, setLessons) => { setLessons(decryptedLessons); setDecryptionPerformed(true); } catch (error) { - console.error('Error decrypting lessons:', error); + console.error("Error decrypting lessons:", error); } } setLoading(false); @@ -171,13 +185,16 @@ 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]); + const setCompleted = useCallback((lessonId) => { + setCompletedLessons((prev) => [...prev, lessonId]); }, []); const fetchAuthor = useCallback( - async pubkey => { + async (pubkey) => { const author = await ndk.getUser({ pubkey }); const profile = await author.fetchProfile(); const fields = await findKind0Fields(profile); @@ -217,30 +234,67 @@ const Course = () => { } }, [router.isReady, router.query]); - const handleAccordionChange = e => { + 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)); + } else if (session?.user?.pubkey) { + setNpub(nip19.npubEncode(session.user.pubkey)); + } + }, [session]); + + const handleAccordionChange = (e) => { const newIndex = e.index === expandedIndex ? null : e.index; setExpandedIndex(newIndex); if (newIndex !== null) { - router.push(`/course/${router.query.slug}?active=${newIndex}`, undefined, { shallow: true }); + router.push( + `/course/${router.query.slug}?active=${newIndex}`, + undefined, + { shallow: true } + ); } else { router.push(`/course/${router.query.slug}`, undefined, { shallow: true }); } }; - const handlePaymentSuccess = async response => { + const handlePaymentSuccess = async (response) => { if (response && response?.preimage) { const updated = await update(); - showToast('success', 'Payment Success', 'You have successfully purchased this course'); + showToast( + "success", + "Payment Success", + "You have successfully purchased this course" + ); } else { - showToast('error', 'Error', 'Failed to purchase course. Please try again.'); + showToast( + "error", + "Error", + "Failed to purchase course. Please try again." + ); } }; - const handlePaymentError = error => { + const handlePaymentError = (error) => { showToast( - 'error', - 'Payment Error', + "error", + "Payment Error", `Failed to purchase course. Please try again. Error: ${error}` ); }; @@ -253,8 +307,11 @@ const Course = () => { ); } - const renderLesson = lesson => { - if (lesson.topics?.includes('video') && lesson.topics?.includes('document')) { + const renderLesson = (lesson) => { + if ( + lesson.topics?.includes("video") && + lesson.topics?.includes("document") + ) { return ( { setCompleted={setCompleted} /> ); - } else if (lesson.type === 'video' && !lesson.topics?.includes('document')) { + } else if ( + lesson.type === "video" && + !lesson.topics?.includes("document") + ) { return ( { setCompleted={setCompleted} /> ); - } else if (lesson.type === 'document' && !lesson.topics?.includes('video')) { + } else if ( + lesson.type === "document" && + !lesson.topics?.includes("video") + ) { return ( { @@ -327,12 +390,38 @@ const Course = () => { } > -
{renderLesson(lesson)}
+
+ {renderLesson(lesson)} + {nAddresses[lesson.id] && ( +
+ {!paidCourse || + decryptionPerformed || + session?.user?.role?.subscribed ? ( + + ) : ( +
+

+ Comments are only available to course purchasers, + subscribers, and the course creator. +

+
+ )} +
+ )} +
))}
- {course?.content && } + {course?.content && ( + + )}
);