);
- } 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])}
+
) : (