import React, { useEffect, useState, useCallback, useMemo, useRef } 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 CourseSidebar from '@/components/content/courses/CourseSidebar'; import { useNDKContext } from '@/context/NDKContext'; import { useSession } from 'next-auth/react'; import { nip19 } from 'nostr-tools'; import { useToast } from '@/hooks/useToast'; import { ProgressSpinner } from 'primereact/progressspinner'; import { useDecryptContent } from '@/hooks/encryption/useDecryptContent'; import ZapThreadsWrapper from '@/components/ZapThreadsWrapper'; import appConfig from '@/config/appConfig'; import useWindowWidth from '@/hooks/useWindowWidth'; import MenuTab from '@/components/menutab/MenuTab'; import { Tag } from 'primereact/tag'; import MarkdownDisplay from '@/components/markdown/MarkdownDisplay'; const useCourseData = (ndk, fetchAuthor, router) => { const [course, setCourse] = useState(null); const [lessonIds, setLessonIds] = useState([]); const [paidCourse, setPaidCourse] = useState(null); const [loading, setLoading] = useState(true); const { showToast } = useToast(); useEffect(() => { if (!router.isReady) return; const { slug } = router.query; let id; const fetchCourseId = async () => { if (slug.includes('naddr')) { const { data } = nip19.decode(slug); if (!data?.identifier) { showToast('error', 'Error', 'Resource not found'); return null; } return data.identifier; } else { return slug; } }; const fetchCourse = async courseId => { try { await ndk.connect(); 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 parsedCourse = { ...parseCourseEvent(event), author }; return { parsedCourse, lessonIds }; } catch (error) { console.error('Error fetching event:', error); return null; } }; const initializeCourse = async () => { setLoading(true); id = await fetchCourseId(); if (!id) { setLoading(false); return; } const courseData = await fetchCourse(id); if (courseData) { const { parsedCourse, lessonIds } = courseData; setCourse(parsedCourse); setLessonIds(lessonIds); setPaidCourse(parsedCourse.price && parsedCourse.price > 0); } setLoading(false); }; initializeCourse(); }, [router.isReady, router.query, ndk, fetchAuthor, showToast]); return { course, lessonIds, paidCourse, loading }; }; const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => { const [lessons, setLessons] = useState([]); const [uniqueLessons, setUniqueLessons] = useState([]); const { showToast } = useToast(); useEffect(() => { if (lessonIds.length > 0 && pubkey) { const fetchLessons = async () => { try { await ndk.connect(); // Create a single filter with all lesson IDs to avoid multiple calls const filter = { '#d': lessonIds, kinds: [30023, 30402], authors: [pubkey], }; const events = await ndk.fetchEvents(filter); const newLessons = []; // Process events (no need to check for duplicates here) for (const event of events) { const author = await fetchAuthor(event.pubkey); const parsedLesson = { ...parseEvent(event), author }; newLessons.push(parsedLesson); } setLessons(newLessons); } catch (error) { console.error('Error fetching events:', error); } }; fetchLessons(); } }, [lessonIds, ndk, fetchAuthor, pubkey]); // Keep this deduplication logic using Map useEffect(() => { const newUniqueLessons = Array.from( new Map(lessons.map(lesson => [lesson.id, lesson])).values() ); setUniqueLessons(newUniqueLessons); }, [lessons]); return { lessons, uniqueLessons, setLessons }; }; const useDecryption = (session, paidCourse, course, lessons, setLessons, router) => { const [decryptedLessonIds, setDecryptedLessonIds] = useState({}); const [loading, setLoading] = useState(false); const { decryptContent } = useDecryptContent(); const processingRef = useRef(false); const lastLessonIdRef = useRef(null); const retryCountRef = useRef({}); const MAX_RETRIES = 3; // Get the current active lesson const currentLessonIndex = router.query.active ? parseInt(router.query.active, 10) : 0; const currentLesson = lessons.length > 0 ? lessons[currentLessonIndex] : null; const currentLessonId = currentLesson?.id; // Check if the current lesson has been decrypted const isCurrentLessonDecrypted = !paidCourse || (currentLessonId && decryptedLessonIds[currentLessonId]); // Check user access const hasAccess = useMemo(() => { if (!session?.user || !paidCourse || !course) return false; return ( session.user.purchased?.some(purchase => purchase.courseId === course?.d) || session.user?.role?.subscribed || session.user?.pubkey === course?.pubkey ); }, [session, paidCourse, course]); // Reset retry count when lesson changes useEffect(() => { if (currentLessonId && lastLessonIdRef.current !== currentLessonId) { retryCountRef.current[currentLessonId] = 0; } }, [currentLessonId]); // Simplified decrypt function const decryptCurrentLesson = useCallback(async () => { if (!currentLesson || !hasAccess || !paidCourse) return; if (processingRef.current) return; if (decryptedLessonIds[currentLesson.id]) return; if (!currentLesson.content) return; // Check retry count if (!retryCountRef.current[currentLesson.id]) { retryCountRef.current[currentLesson.id] = 0; } // Limit maximum retries if (retryCountRef.current[currentLesson.id] >= MAX_RETRIES) { return; } // Increment retry count retryCountRef.current[currentLesson.id]++; try { processingRef.current = true; setLoading(true); // Add safety timeout to prevent infinite processing const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Decryption timeout')), 10000) ); // Use a separate try-catch for the race let decryptedContent; try { // Race between decryption and timeout decryptedContent = await Promise.race([ decryptContent(currentLesson.content), timeoutPromise ]); } catch (error) { // If timeout or network error, schedule a retry setTimeout(() => { processingRef.current = false; decryptCurrentLesson(); }, 5000); throw error; } if (!decryptedContent) { return; } // Update the lessons array with decrypted content const updatedLessons = lessons.map(lesson => lesson.id === currentLesson.id ? { ...lesson, content: decryptedContent } : lesson ); setLessons(updatedLessons); // Mark this lesson as decrypted setDecryptedLessonIds(prev => ({ ...prev, [currentLesson.id]: true })); // Reset retry counter on success retryCountRef.current[currentLesson.id] = 0; } catch (error) { // Silent error handling to prevent UI disruption } finally { setLoading(false); processingRef.current = false; } }, [currentLesson, hasAccess, paidCourse, decryptContent, lessons, setLessons, decryptedLessonIds]); // Run decryption when lesson changes useEffect(() => { if (!currentLessonId) return; // Skip if the lesson hasn't changed, unless it failed decryption previously if (lastLessonIdRef.current === currentLessonId && decryptedLessonIds[currentLessonId]) return; // Update the last processed lesson id lastLessonIdRef.current = currentLessonId; if (hasAccess && paidCourse && !decryptedLessonIds[currentLessonId]) { decryptCurrentLesson(); } }, [currentLessonId, hasAccess, paidCourse, decryptedLessonIds, decryptCurrentLesson]); return { decryptionPerformed: isCurrentLessonDecrypted, loading, decryptedLessonIds }; }; const Course = () => { const router = useRouter(); const { ndk, addSigner } = useNDKContext(); const { data: session, update } = useSession(); const { showToast } = useToast(); const [activeIndex, setActiveIndex] = useState(0); const [completedLessons, setCompletedLessons] = useState([]); const [nAddresses, setNAddresses] = useState({}); const [nsec, setNsec] = useState(null); const [npub, setNpub] = useState(null); const [sidebarVisible, setSidebarVisible] = useState(false); const [nAddress, setNAddress] = useState(null); const windowWidth = useWindowWidth(); const isMobileView = windowWidth <= 968; const [activeTab, setActiveTab] = useState('overview'); // Default to overview tab const navbarHeight = 60; // Match the height from Navbar component // Memoized function to get the tab map based on view mode const getTabMap = useMemo(() => { const baseTabMap = ['overview', 'content', 'qa']; if (isMobileView) { const mobileTabMap = [...baseTabMap]; mobileTabMap.splice(2, 0, 'lessons'); return mobileTabMap; } return baseTabMap; }, [isMobileView]); useEffect(() => { if (router.isReady && router.query.slug) { const { slug } = router.query; if (slug.includes('naddr')) { setNAddress(slug); } else { console.warn('No naddress found in slug'); showToast('error', 'Error', 'Course identifier not found in URL'); setTimeout(() => { router.push('/courses'); // Redirect to courses page }, 3000); } } }, [router.isReady, router.query.slug, showToast, router]); useEffect(() => { if (router.isReady) { const { active } = router.query; if (active !== undefined) { setActiveIndex(parseInt(active, 10)); // If we have an active lesson, switch to content tab setActiveTab('content'); } else { setActiveIndex(0); // Default to overview tab when no active parameter setActiveTab('overview'); } // Auto-open sidebar on desktop, close on mobile setSidebarVisible(!isMobileView); } }, [router.isReady, router.query, isMobileView]); const setCompleted = useCallback(lessonId => { setCompletedLessons(prev => [...prev, lessonId]); }, []); const fetchAuthor = useCallback( async pubkey => { const author = await ndk.getUser({ pubkey }); const profile = await author.fetchProfile(); const fields = await findKind0Fields(profile); return fields; }, [ndk] ); const { course, lessonIds, paidCourse, loading: courseLoading, } = useCourseData(ndk, fetchAuthor, router); const { lessons, uniqueLessons, setLessons } = useLessons( ndk, fetchAuthor, lessonIds, course?.pubkey ); const { decryptionPerformed, loading: decryptionLoading, decryptedLessonIds } = useDecryption( session, paidCourse, course, lessons, setLessons, router ); 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 isAuthorized = session?.user?.role?.subscribed || session?.user?.pubkey === course?.pubkey || !paidCourse || session?.user?.purchased?.some(purchase => purchase.courseId === course?.d) const handleLessonSelect = index => { setActiveIndex(index); router.push(`/course/${router.query.slug}?active=${index}`, undefined, { shallow: true }); // On mobile, switch to content tab after selection if (isMobileView) { setActiveTab('content'); setSidebarVisible(false); } }; const handlePaymentSuccess = async response => { if (response && response?.preimage) { const updated = await update(); showToast('success', 'Payment Success', 'You have successfully purchased this course'); } else { showToast('error', 'Error', 'Failed to purchase course. Please try again.'); } }; const handlePaymentError = error => { showToast( 'error', 'Payment Error', `Failed to purchase course. Please try again. Error: ${error}` ); }; const toggleTab = (index) => { const tabName = getTabMap[index]; setActiveTab(tabName); // Only show/hide sidebar on mobile - desktop keeps sidebar visible if (isMobileView) { setSidebarVisible(tabName === 'lessons'); } }; const handleToggleSidebar = () => { setSidebarVisible(!sidebarVisible); }; // Map active tab name back to index for MenuTab const getActiveTabIndex = () => { return getTabMap.indexOf(activeTab); }; // Create tab items for MenuTab const getTabItems = () => { const items = [ { label: 'Overview', icon: 'pi pi-home', }, { label: 'Content', icon: 'pi pi-book', } ]; // Add lessons tab only on mobile if (isMobileView) { items.push({ label: 'Lessons', icon: 'pi pi-list', }); } items.push({ label: 'Comments', icon: 'pi pi-comments', }); return items; }; // Add keyboard navigation support for tabs useEffect(() => { const handleKeyDown = (e) => { if (e.key === 'ArrowRight') { const currentIndex = getActiveTabIndex(); const nextIndex = (currentIndex + 1) % getTabMap.length; toggleTab(nextIndex); } else if (e.key === 'ArrowLeft') { const currentIndex = getActiveTabIndex(); const prevIndex = (currentIndex - 1 + getTabMap.length) % getTabMap.length; toggleTab(prevIndex); } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [activeTab, getTabMap, toggleTab]); // Render the QA section (empty for now) const renderQASection = () => { return (
Comments are only available to content purchasers, subscribers, and the content creator.
Select a lesson from the sidebar to begin learning.