refactor for recalling /decrypt on lesson change

This commit is contained in:
austinkelsay 2025-05-05 18:51:17 -05:00
parent c72ce2d8ad
commit 333cb30e31
No known key found for this signature in database
GPG Key ID: 5A763922E5BA08EE
7 changed files with 206 additions and 92 deletions

View File

@ -167,10 +167,17 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
}, [videoCompleted, videoTracking, lesson.id, setCompleted, isVideo]); }, [videoCompleted, videoTracking, lesson.id, setCompleted, isVideo]);
const renderContent = () => { const renderContent = () => {
if (isPaid && decryptionPerformed) { if (!lesson?.content) {
if (isVideo) {
return (
<div className="w-full aspect-video rounded-lg flex flex-col items-center justify-center bg-gray-800">
<p className="text-center text-gray-400">No content available for this lesson.</p>
</div>
);
}
return ( return (
<div ref={mdDisplayRef}> <div className="w-full p-8 rounded-lg flex flex-col items-center justify-center bg-gray-800">
<MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" /> <p className="text-center text-gray-400">No content available for this lesson.</p>
</div> </div>
); );
} }
@ -209,15 +216,11 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
); );
} }
if (lesson?.content) { return (
return ( <div ref={mdDisplayRef}>
<div ref={mdDisplayRef}> <MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />
<MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" /> </div>
</div> );
);
}
return null;
}; };
return ( return (

View File

@ -36,7 +36,10 @@ const CourseSidebar = ({
} }
${isMobileView ? 'mb-3' : 'mb-2'} ${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}`;
}}
> >
<div className={`flex items-start p-3 cursor-pointer ${isMobileView ? 'p-4' : 'p-3'}`}> <div className={`flex items-start p-3 cursor-pointer ${isMobileView ? 'p-4' : 'p-3'}`}>
{lesson.image && ( {lesson.image && (

View File

@ -113,9 +113,14 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
}, [isCompleted, lesson.id, setCompleted, isTracking]); }, [isCompleted, lesson.id, setCompleted, isTracking]);
const renderContent = () => { const renderContent = () => {
if (isPaid && decryptionPerformed) { if (!lesson?.content) {
return <MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />; return (
<div className="w-full p-8 rounded-lg flex flex-col items-center justify-center bg-gray-800">
<p className="text-center text-gray-400">No content available for this lesson.</p>
</div>
);
} }
if (isPaid && !decryptionPerformed) { if (isPaid && !decryptionPerformed) {
return ( return (
<div className="w-full p-8 rounded-lg flex flex-col items-center justify-center"> <div className="w-full p-8 rounded-lg flex flex-col items-center justify-center">
@ -128,10 +133,8 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
</div> </div>
); );
} }
if (lesson?.content) {
return <MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />; return <MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />;
}
return null;
}; };
return ( return (

View File

@ -163,13 +163,17 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
}, [decryptionPerformed, isPaid, checkDuration]); }, [decryptionPerformed, isPaid, checkDuration]);
const renderContent = () => { const renderContent = () => {
if (isPaid && decryptionPerformed) { // Content not available
if (!lesson?.content) {
return ( return (
<div ref={mdDisplayRef}> <div className="w-full aspect-video rounded-lg flex flex-col items-center justify-center bg-gray-800">
<MarkdownDisplay content={lesson.content} className="p-0 rounded-lg w-full" /> <p className="text-center text-gray-400">No content available for this lesson.</p>
</div> </div>
); );
} else if (isPaid && !decryptionPerformed) { }
// Paid content that needs to be purchased
if (isPaid && !decryptionPerformed) {
return ( return (
<div className="w-full aspect-video rounded-lg flex flex-col items-center justify-center relative overflow-hidden"> <div className="w-full aspect-video rounded-lg flex flex-col items-center justify-center relative overflow-hidden">
<div <div
@ -189,14 +193,14 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
</p> </p>
</div> </div>
); );
} else if (lesson?.content) {
return (
<div ref={mdDisplayRef}>
<MarkdownDisplay content={lesson.content} className="p-0 rounded-lg w-full" />
</div>
);
} }
return null;
// Content is available and decrypted (or free)
return (
<div ref={mdDisplayRef}>
<MarkdownDisplay content={lesson.content} className="p-0 rounded-lg w-full" />
</div>
);
}; };
return ( return (

View File

@ -11,7 +11,8 @@ const appConfig = {
], ],
authorPubkeys: [ authorPubkeys: [
'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741', 'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741',
'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345' 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345',
'6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4'
], ],
customLightningAddresses: [ customLightningAddresses: [
{ {

View File

@ -1,30 +1,63 @@
import { useState } from 'react'; import { useState, useRef } from 'react';
import axios from 'axios'; import axios from 'axios';
export const useDecryptContent = () => { export const useDecryptContent = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const inProgressRef = useRef(false);
const decryptContent = async encryptedContent => { const cachedResults = useRef({});
setIsLoading(true);
setError(null); const decryptContent = async (encryptedContent) => {
// Validate input
try { if (!encryptedContent) {
const response = await axios.post('/api/decrypt', { encryptedContent });
if (response.status !== 200) {
throw new Error('Failed to decrypt content');
}
const decryptedContent = response.data.decryptedContent;
setIsLoading(false);
return decryptedContent;
} catch (err) {
setError(err.message);
setIsLoading(false);
return null; return null;
} }
// Prevent multiple simultaneous calls
if (inProgressRef.current) {
// Wait for a small delay to prevent tight loop
await new Promise(resolve => setTimeout(resolve, 100));
// Return a cached result if we have one
const firstChars = encryptedContent.substring(0, 20);
if (cachedResults.current[firstChars]) {
return cachedResults.current[firstChars];
}
return null;
}
// Check if we've already decrypted this content
const firstChars = encryptedContent.substring(0, 20);
if (cachedResults.current[firstChars]) {
return cachedResults.current[firstChars];
}
try {
inProgressRef.current = true;
setIsLoading(true);
setError(null);
const response = await axios.post('/api/decrypt', { encryptedContent });
if (response.status !== 200) {
throw new Error(`Failed to decrypt: ${response.statusText}`);
}
const decryptedContent = response.data.decryptedContent;
// Cache the result
cachedResults.current[firstChars] = decryptedContent;
return decryptedContent;
} catch (error) {
setError(error.message || 'Decryption failed');
return null;
} finally {
setIsLoading(false);
inProgressRef.current = false;
}
}; };
return { decryptContent, isLoading, error }; return { decryptContent, isLoading, error };
}; };

View File

@ -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 { useRouter } from 'next/router';
import { parseCourseEvent, parseEvent, findKind0Fields } from '@/utils/nostr'; import { parseCourseEvent, parseEvent, findKind0Fields } from '@/utils/nostr';
import CourseDetails from '@/components/content/courses/CourseDetails'; import CourseDetails from '@/components/content/courses/CourseDetails';
@ -135,42 +135,102 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => {
return { lessons, uniqueLessons, setLessons }; return { lessons, uniqueLessons, setLessons };
}; };
const useDecryption = (session, paidCourse, course, lessons, setLessons) => { const useDecryption = (session, paidCourse, course, lessons, setLessons, router) => {
const [decryptionPerformed, setDecryptionPerformed] = useState(false); const [decryptedLessonIds, setDecryptedLessonIds] = useState({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(false);
const { decryptContent } = useDecryptContent(); const { decryptContent } = useDecryptContent();
const processingRef = useRef(false);
useEffect(() => { const lastLessonIdRef = useRef(null);
const decrypt = async () => {
if (session?.user && paidCourse && !decryptionPerformed) { // Get the current active lesson
setLoading(true); const currentLessonIndex = router.query.active ? parseInt(router.query.active, 10) : 0;
const canAccess = const currentLesson = lessons.length > 0 ? lessons[currentLessonIndex] : null;
session.user.purchased?.some(purchase => purchase.courseId === course?.d) || const currentLessonId = currentLesson?.id;
session.user?.role?.subscribed ||
session.user?.pubkey === course?.pubkey; // Check if the current lesson has been decrypted
const isCurrentLessonDecrypted =
if (canAccess && lessons.length > 0) { !paidCourse ||
try { (currentLessonId && decryptedLessonIds[currentLessonId]);
const decryptedLessons = await Promise.all(
lessons.map(async lesson => { // Check user access
const decryptedContent = await decryptContent(lesson.content); const hasAccess = useMemo(() => {
return { ...lesson, content: decryptedContent }; if (!session?.user || !paidCourse || !course) return false;
})
); return (
setLessons(decryptedLessons); session.user.purchased?.some(purchase => purchase.courseId === course?.d) ||
setDecryptionPerformed(true); session.user?.role?.subscribed ||
} catch (error) { session.user?.pubkey === course?.pubkey
console.error('Error decrypting lessons:', error); );
} }, [session, paidCourse, course]);
}
setLoading(false); // 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;
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)
);
// Race between decryption and timeout
const decryptedContent = await Promise.race([
decryptContent(currentLesson.content),
timeoutPromise
]);
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
}));
} catch (error) {
// Silent error handling to prevent UI disruption
} finally {
setLoading(false); setLoading(false);
}; processingRef.current = false;
decrypt(); }
}, [session, paidCourse, course, lessons, decryptionPerformed, setLessons]); }, [currentLesson, hasAccess, paidCourse, decryptContent, lessons, setLessons, decryptedLessonIds]);
return { decryptionPerformed, loading }; // Run decryption when lesson changes
useEffect(() => {
if (!currentLessonId) return;
// Skip if the lesson hasn't changed
if (lastLessonIdRef.current === 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 Course = () => {
@ -262,12 +322,13 @@ const Course = () => {
course?.pubkey course?.pubkey
); );
const { decryptionPerformed, loading: decryptionLoading } = useDecryption( const { decryptionPerformed, loading: decryptionLoading, decryptedLessonIds } = useDecryption(
session, session,
paidCourse, paidCourse,
course, course,
lessons, lessons,
setLessons setLessons,
router
); );
useEffect(() => { useEffect(() => {
@ -478,23 +539,27 @@ const Course = () => {
} }
const renderLesson = lesson => { 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')) { if (lesson.topics?.includes('video') && lesson.topics?.includes('document')) {
return ( return (
<CombinedLesson <CombinedLesson
lesson={lesson} lesson={lesson}
course={course} course={course}
decryptionPerformed={decryptionPerformed} decryptionPerformed={lessonDecrypted}
isPaid={paidCourse} isPaid={paidCourse}
setCompleted={setCompleted} setCompleted={setCompleted}
/> />
); );
} else if (lesson.type === 'video' && !lesson.topics?.includes('document')) { } else if (lesson.type === 'video' && !lesson.topics?.includes('document')) {
return ( return (
<VideoLesson <VideoLesson
lesson={lesson} lesson={lesson}
course={course} course={course}
decryptionPerformed={decryptionPerformed} decryptionPerformed={lessonDecrypted}
isPaid={paidCourse} isPaid={paidCourse}
setCompleted={setCompleted} setCompleted={setCompleted}
/> />
@ -504,7 +569,7 @@ const Course = () => {
<DocumentLesson <DocumentLesson
lesson={lesson} lesson={lesson}
course={course} course={course}
decryptionPerformed={decryptionPerformed} decryptionPerformed={lessonDecrypted}
isPaid={paidCourse} isPaid={paidCourse}
setCompleted={setCompleted} setCompleted={setCompleted}
/> />
@ -545,7 +610,9 @@ const Course = () => {
<div className={`${activeTab === 'content' ? 'block' : 'hidden'}`}> <div className={`${activeTab === 'content' ? 'block' : 'hidden'}`}>
{uniqueLessons.length > 0 && uniqueLessons[activeIndex] ? ( {uniqueLessons.length > 0 && uniqueLessons[activeIndex] ? (
<div className="bg-gray-800 rounded-lg shadow-sm overflow-hidden"> <div className="bg-gray-800 rounded-lg shadow-sm overflow-hidden">
{renderLesson(uniqueLessons[activeIndex])} <div key={`lesson-${uniqueLessons[activeIndex].id}`}>
{renderLesson(uniqueLessons[activeIndex])}
</div>
</div> </div>
) : ( ) : (
<div className="text-center bg-gray-800 rounded-lg p-8"> <div className="text-center bg-gray-800 rounded-lg p-8">