diff --git a/src/components/content/courses/CombinedLesson.js b/src/components/content/courses/CombinedLesson.js index ba4eeab..d837973 100644 --- a/src/components/content/courses/CombinedLesson.js +++ b/src/components/content/courses/CombinedLesson.js @@ -167,10 +167,17 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple }, [videoCompleted, videoTracking, lesson.id, setCompleted, isVideo]); const renderContent = () => { - if (isPaid && decryptionPerformed) { + if (!lesson?.content) { + if (isVideo) { + return ( +
+

No content available for this lesson.

+
+ ); + } return ( -
- +
+

No content available for this lesson.

); } @@ -209,15 +216,11 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple ); } - if (lesson?.content) { - return ( -
- -
- ); - } - - return null; + return ( +
+ +
+ ); }; return ( diff --git a/src/components/content/courses/CourseSidebar.js b/src/components/content/courses/CourseSidebar.js index a757233..189e7f1 100644 --- a/src/components/content/courses/CourseSidebar.js +++ b/src/components/content/courses/CourseSidebar.js @@ -36,7 +36,10 @@ const CourseSidebar = ({ } ${isMobileView ? 'mb-3' : 'mb-2'} `} - onClick={() => onLessonSelect(index)} + onClick={() => { + // Force full page refresh to trigger proper decryption + window.location.href = `/course/${window.location.pathname.split('/').pop()}?active=${index}`; + }} >
{lesson.image && ( diff --git a/src/components/content/courses/DocumentLesson.js b/src/components/content/courses/DocumentLesson.js index 8af1ba7..d13cb49 100644 --- a/src/components/content/courses/DocumentLesson.js +++ b/src/components/content/courses/DocumentLesson.js @@ -113,9 +113,14 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple }, [isCompleted, lesson.id, setCompleted, isTracking]); const renderContent = () => { - if (isPaid && decryptionPerformed) { - return ; + if (!lesson?.content) { + return ( +
+

No content available for this lesson.

+
+ ); } + if (isPaid && !decryptionPerformed) { return (
@@ -128,10 +133,8 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
); } - if (lesson?.content) { - return ; - } - return null; + + return ; }; return ( diff --git a/src/components/content/courses/VideoLesson.js b/src/components/content/courses/VideoLesson.js index 326ff53..1a704e0 100644 --- a/src/components/content/courses/VideoLesson.js +++ b/src/components/content/courses/VideoLesson.js @@ -163,13 +163,17 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }, [decryptionPerformed, isPaid, checkDuration]); const renderContent = () => { - if (isPaid && decryptionPerformed) { + // Content not available + if (!lesson?.content) { return ( -
- +
+

No content available for this lesson.

); - } else if (isPaid && !decryptionPerformed) { + } + + // Paid content that needs to be purchased + if (isPaid && !decryptionPerformed) { return (
); - } else if (lesson?.content) { - return ( -
- -
- ); } - return null; + + // Content is available and decrypted (or free) + return ( +
+ +
+ ); }; return ( diff --git a/src/hooks/encryption/useDecryptContent.js b/src/hooks/encryption/useDecryptContent.js index 5584f02..3d46f0a 100644 --- a/src/hooks/encryption/useDecryptContent.js +++ b/src/hooks/encryption/useDecryptContent.js @@ -1,30 +1,101 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import axios from 'axios'; export const useDecryptContent = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - - const decryptContent = async encryptedContent => { - setIsLoading(true); - setError(null); - - try { - const response = await axios.post('/api/decrypt', { encryptedContent }); - - if (response.status !== 200) { - throw new Error('Failed to decrypt content'); + // Map of in-progress decryption promises, keyed by content hash + const inProgressMap = useRef(new Map()); + const cachedResults = useRef({}); + + const decryptContent = async (encryptedContent) => { + // Validate input + if (!encryptedContent) { + return null; + } + + // Use first 20 chars as our cache/lock key + const cacheKey = encryptedContent.substring(0, 20); + + // Check if we've already decrypted this content + if (cachedResults.current[cacheKey]) { + return cachedResults.current[cacheKey]; + } + + // Check if this specific content is already being decrypted + if (inProgressMap.current.has(cacheKey)) { + // Return the existing promise for this content + try { + return await inProgressMap.current.get(cacheKey); + } catch (error) { + // If the existing promise rejects, we'll try again below + if (error.name !== 'AbortError') { + console.warn('Previous decryption attempt failed, retrying'); + } } - - const decryptedContent = response.data.decryptedContent; - setIsLoading(false); - return decryptedContent; - } catch (err) { - setError(err.message); - setIsLoading(false); + } + + // Create abort controller for this request + const abortController = new AbortController(); + + // Create a new decryption promise for this content + const decryptPromise = (async () => { + try { + setIsLoading(true); + setError(null); + + const response = await axios.post('/api/decrypt', + { encryptedContent }, + { signal: abortController.signal } + ); + + if (response.status !== 200) { + throw new Error(`Failed to decrypt: ${response.statusText}`); + } + + const decryptedContent = response.data.decryptedContent; + + // Cache the successful result + cachedResults.current[cacheKey] = decryptedContent; + + return decryptedContent; + } catch (error) { + // Handle abort errors specifically + if (axios.isCancel(error)) { + throw new DOMException('Decryption aborted', 'AbortError'); + } + + setError(error.message || 'Decryption failed'); + // Re-throw to signal failure to awaiter + throw error; + } finally { + setIsLoading(false); + // Remove this promise from the in-progress map + inProgressMap.current.delete(cacheKey); + } + })(); + + // Store the promise and abort controller in our map + const abortablePromise = { + promise: decryptPromise, + abort: () => abortController.abort() + }; + + inProgressMap.current.set(cacheKey, decryptPromise); + + // Function to handle timeouts from parent callers + decryptPromise.cancel = () => { + abortController.abort(); + }; + + // Return the promise + try { + return await decryptPromise; + } catch (error) { + // We've already set the error state in the promise return null; } }; - + return { decryptContent, isLoading, error }; }; diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index 22de673..b03be74 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +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'; @@ -135,42 +135,151 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => { return { lessons, uniqueLessons, setLessons }; }; -const useDecryption = (session, paidCourse, course, lessons, setLessons) => { - const [decryptionPerformed, setDecryptionPerformed] = useState(false); - const [loading, setLoading] = useState(true); +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(() => { - const decrypt = async () => { - if (session?.user && paidCourse && !decryptionPerformed) { - setLoading(true); - const canAccess = - 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 => { - const decryptedContent = await decryptContent(lesson.content); - return { ...lesson, content: decryptedContent }; - }) - ); - setLessons(decryptedLessons); - setDecryptionPerformed(true); - } catch (error) { - console.error('Error decrypting lessons:', error); + 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); + + // Start the decryption process + const decryptionPromise = decryptContent(currentLesson.content); + + // Add safety timeout to prevent infinite processing + let timeoutId; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + // Cancel the in-flight request when timeout occurs + if (decryptionPromise.cancel) { + decryptionPromise.cancel(); } - } - setLoading(false); + 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([ + decryptionPromise, + timeoutPromise + ]); + + // Clear the timeout if decryption wins + clearTimeout(timeoutId); + } 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); - }; - decrypt(); - }, [session, paidCourse, course, lessons, decryptionPerformed, setLessons]); - - return { decryptionPerformed, loading }; + 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 = () => { @@ -262,12 +371,13 @@ const Course = () => { course?.pubkey ); - const { decryptionPerformed, loading: decryptionLoading } = useDecryption( + const { decryptionPerformed, loading: decryptionLoading, decryptedLessonIds } = useDecryption( session, paidCourse, course, lessons, - setLessons + setLessons, + router ); useEffect(() => { @@ -478,23 +588,27 @@ const Course = () => { } const renderLesson = lesson => { + if (!lesson) return null; + + // Check if this specific lesson is decrypted + const lessonDecrypted = !paidCourse || decryptedLessonIds[lesson.id] || false; + if (lesson.topics?.includes('video') && lesson.topics?.includes('document')) { return ( - ); } else if (lesson.type === 'video' && !lesson.topics?.includes('document')) { return ( @@ -504,7 +618,7 @@ const Course = () => { @@ -545,7 +659,9 @@ const Course = () => {
{uniqueLessons.length > 0 && uniqueLessons[activeIndex] ? (
- {renderLesson(uniqueLessons[activeIndex])} +
+ {renderLesson(uniqueLessons[activeIndex])} +
) : (