Completely removed useNostr hook, using NDK now

This commit is contained in:
austinkelsay 2024-08-06 19:52:06 -05:00
parent 7b69ccfb66
commit 658cfe31a9
11 changed files with 240 additions and 1096 deletions

View File

@ -53,7 +53,6 @@ export default function CourseDetails({ processedEvent }) {
const author = await ndk.getUser({ pubkey });
const profile = await author.fetchProfile();
const fields = await findKind0Fields(profile);
console.log('fields:', fields);
if (fields) {
setAuthor(fields);
}

View File

@ -1,16 +1,20 @@
import React, { useState, useEffect } from "react";
import React, { useState } from "react";
import axios from "axios";
import { InputText } from "primereact/inputtext";
import { InputNumber } from "primereact/inputnumber";
import { InputSwitch } from "primereact/inputswitch";
import { Button } from "primereact/button";
import { Dropdown } from "primereact/dropdown";
import { ProgressSpinner } from "primereact/progressspinner";
import { v4 as uuidv4, v4 } from 'uuid';
import { useLocalStorageWithEffect } from "@/hooks/useLocalStorage";
import { useNostr } from "@/hooks/useNostr";
import { useNDKContext } from "@/context/NDKContext";
import { useRouter } from "next/router";
import { useToast } from "@/hooks/useToast";
import { nip19 } from "nostr-tools"
import { useWorkshopsQuery } from "@/hooks/nostrQueries/content/useWorkshopsQuery";
import { useResourcesQuery } from "@/hooks/nostrQueries/content/useResourcesQuery";
import { useDraftsQuery } from "@/hooks/apiQueries/useDraftsQuery";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { parseEvent } from "@/utils/nostr";
import ContentDropdownItem from "@/components/content/dropdowns/ContentDropdownItem";
import 'primeicons/primeicons.css';
@ -24,57 +28,25 @@ const CourseForm = () => {
const [lessons, setLessons] = useState([{ id: uuidv4(), title: 'Select a lesson' }]);
const [selectedLessons, setSelectedLessons] = useState([]);
const [topics, setTopics] = useState(['']);
const [user, setUser] = useLocalStorageWithEffect('user', {});
const [drafts, setDrafts] = useState([]);
const [resources, setResources] = useState([]);
const [workshops, setWorkshops] = useState([]);
const { fetchResources, fetchWorkshops, publish, fetchSingleEvent } = useNostr();
const [pubkey, setPubkey] = useState('');
const { resources, resourcesLoading, resourcesError } = useResourcesQuery();
const { workshops, workshopsLoading, workshopsError } = useWorkshopsQuery();
const { drafts, draftsLoading, draftsError } = useDraftsQuery();
const [user, setUser] = useLocalStorageWithEffect('user', {});
const ndk = useNDKContext();
const router = useRouter();
const { showToast } = useToast();
const fetchAllContent = async () => {
try {
// Fetch drafts from the database
const draftsResponse = await axios.get(`/api/drafts/all/${user.id}`);
const drafts = draftsResponse.data;
// Fetch resources and workshops from Nostr
const resources = await fetchResources();
const workshops = await fetchWorkshops();
if (drafts.length > 0) {
setDrafts(drafts);
}
if (resources.length > 0) {
setResources(resources);
}
if (workshops.length > 0) {
setWorkshops(workshops);
}
} catch (err) {
console.error(err);
// Handle error
}
};
useEffect(() => {
if (user && user.id) {
fetchAllContent();
}
}, [user]);
/**
* Course Creation Flow:
* 1. Generate a new course ID
* 2. Process each lesson:
* - If unpublished: create event, publish to Nostr, save to DB, delete draft
* - If published: use existing data
* 3. Create and publish course event to Nostr
* 4. Save course to database
* 5. Show success message and redirect to course page
*/
/**
* Course Creation Flow:
* 1. Generate a new course ID
* 2. Process each lesson:
* - If unpublished: create event, publish to Nostr, save to DB, delete draft
* - If published: use existing data
* 3. Create and publish course event to Nostr
* 4. Save course to database
* 5. Show success message and redirect to course page
*/
const handleSubmit = async (e) => {
e.preventDefault();
@ -90,14 +62,13 @@ const CourseForm = () => {
if (!lesson.published_at) {
// Publish unpublished lesson
const event = createLessonEvent(lesson);
const signedEvent = await window.nostr.signEvent(event);
const published = await publish(signedEvent);
const published = await event.publish();
if (!published) {
throw new Error(`Failed to publish lesson: ${lesson.title}`);
}
noteId = signedEvent.id;
noteId = event.id;
// Save to db and delete draft
await Promise.all([
@ -122,8 +93,9 @@ const CourseForm = () => {
// Step 2: Create and publish course
const courseEvent = createCourseEvent(newCourseId, title, summary, coverImage, processedLessons);
const signedCourseEvent = await window.nostr.signEvent(courseEvent);
const published = await publish(signedCourseEvent);
const published = await courseEvent.publish();
console.log('published', published);
if (!published) {
throw new Error('Failed to publish course');
@ -136,7 +108,7 @@ const CourseForm = () => {
resources: {
connect: processedLessons.map(lesson => ({ id: lesson?.d }))
},
noteId: signedCourseEvent.id,
noteId: courseEvent.id,
user: {
connect: { id: user.id }
},
@ -148,7 +120,7 @@ const CourseForm = () => {
// Step 5: Show success message and redirect
showToast('success', 'Course created successfully');
router.push(`/course/${signedCourseEvent.id}`);
router.push(`/course/${courseEvent.id}`);
} catch (error) {
console.error('Error creating course:', error);
@ -156,29 +128,26 @@ const CourseForm = () => {
}
};
const createLessonEvent = (lesson) => ({
kind: lesson.price ? 30402 : 30023,
content: lesson.content,
created_at: Math.floor(Date.now() / 1000),
tags: [
const createLessonEvent = (lesson) => {
const event = new NDKEvent(ndk);
event.kind = lesson.price ? 30402 : 30023;
event.content = lesson.content;
event.tags = [
['d', lesson.id],
['title', lesson.title],
['summary', lesson.summary],
['image', lesson.image],
...lesson.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
...(lesson.price ? [
['price', lesson.price],
['location', `https://plebdevs.com/${lesson.topics[1]}/${lesson.id}`]
] : [])
]
});
];
return event;
};
const createCourseEvent = (courseId, title, summary, coverImage, lessons) => ({
kind: 30004,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: [
const createCourseEvent = (courseId, title, summary, coverImage, lessons) => {
const event = new NDKEvent(ndk);
event.kind = 30004;
event.content = "";
event.tags = [
['d', courseId],
['name', title],
['picture', coverImage],
@ -186,8 +155,9 @@ const CourseForm = () => {
['description', summary],
['l', "Education"],
...lessons.map((lesson) => ['a', `${lesson.kind}:${lesson.pubkey}:${lesson.d}`]),
],
});
];
return event;
};
const handleLessonChange = (e, index) => {
const selectedLessonId = e.value;
@ -235,6 +205,9 @@ const CourseForm = () => {
};
const getContentOptions = (index) => {
if (resourcesLoading || !resources || workshopsLoading || !workshops || draftsLoading || !drafts) {
return [];
}
const draftOptions = drafts.map(draft => ({
label: <ContentDropdownItem content={draft} onSelect={(content) => handleLessonSelect(content, index)} selected={lessons[index] && lessons[index].id === draft.id} />,
value: draft.id
@ -272,7 +245,10 @@ const CourseForm = () => {
];
};
const lessonOptions = getContentOptions();
// const lessonOptions = getContentOptions();
if (resourcesLoading || workshopsLoading || draftsLoading) {
return <ProgressSpinner />;
}
return (
<form onSubmit={handleSubmit}>

View File

@ -1,46 +0,0 @@
import React from 'react';
import { Button } from 'primereact/button';
const EditorHeader = ({ quill }) => {
const embedVideo = () => {
const videoUrl = prompt('Enter the video URL:');
if (videoUrl) {
const videoEmbedCode = `<iframe width="560" height="315" src="${videoUrl}" frameborder="0" allowfullscreen></iframe>`;
quill.editor.clipboard.dangerouslyPasteHTML(videoEmbedCode);
}
};
return (
<React.Fragment>
<span className="ql-formats">
<select className="ql-font"></select>
<select className="ql-size"></select>
</span>
<span className="ql-formats">
<button className="ql-bold"></button>
<button className="ql-italic"></button>
<button className="ql-underline"></button>
<select className="ql-color"></select>
<select className="ql-background"></select>
</span>
<span className="ql-formats">
<button className="ql-list" value="ordered"></button>
<button className="ql-list" value="bullet"></button>
<select className="ql-align"></select>
</span>
<span className="ql-formats">
<button className="ql-link"></button>
<button className="ql-image"></button>
<button className="ql-video"></button>
</span>
<Button
icon="pi pi-video"
className="p-button-outlined p-button-secondary"
onClick={embedVideo}
style={{ marginRight: '0.5rem' }}
/>
</React.Fragment>
);
};
export default EditorHeader;

View File

@ -4,10 +4,8 @@ import { InputText } from "primereact/inputtext";
import { InputNumber } from "primereact/inputnumber";
import { InputSwitch } from "primereact/inputswitch";
import { Button } from "primereact/button";
import { useRouter } from "next/router";
import { useNostr } from "@/hooks/useNostr";
import { useRouter } from "next/router";;
import { useLocalStorageWithEffect } from "@/hooks/useLocalStorage";
import EditorHeader from "./Editor/EditorHeader";
import { useToast } from "@/hooks/useToast";
import dynamic from 'next/dynamic';
const MDEditor = dynamic(
@ -29,7 +27,6 @@ const ResourceForm = ({ draft = null }) => {
const [user] = useLocalStorageWithEffect('user', {});
const { showToast } = useToast();
const { publishAll } = useNostr();
const router = useRouter();
const handleContentChange = useCallback((value) => {
@ -94,151 +91,6 @@ const ResourceForm = ({ draft = null }) => {
}
};
// const saveFreeResource = async (payload) => {
// const newresourceId = uuidv4();
// const event = {
// kind: 30023,
// content: payload.content,
// created_at: Math.floor(Date.now() / 1000),
// tags: [
// ['d', newresourceId],
// ['title', payload.title],
// ['summary', payload.summary],
// ['image', ''],
// ['t', ...topics],
// ['published_at', Math.floor(Date.now() / 1000).toString()],
// ]
// };
// const signedEvent = await window.nostr.signEvent(event);
// const eventVerification = await verifyEvent(signedEvent);
// if (!eventVerification) {
// showToast('error', 'Error', 'Event verification failed. Please try again.');
// return;
// }
// const nAddress = nip19.naddrEncode({
// pubkey: signedEvent.pubkey,
// kind: signedEvent.kind,
// identifier: newresourceId,
// })
// console.log('nAddress:', nAddress);
// const userResponse = await axios.get(`/api/users/${user.pubkey}`)
// if (!userResponse.data) {
// showToast('error', 'Error', 'User not found', 'Please try again.');
// return;
// }
// const resourcePayload = {
// id: newresourceId,
// userId: userResponse.data.id,
// price: 0,
// noteId: nAddress,
// }
// const response = await axios.post(`/api/resources`, resourcePayload);
// console.log('response:', response);
// if (response.status !== 201) {
// showToast('error', 'Error', 'Failed to create resource. Please try again.');
// return;
// }
// const publishResponse = await publishAll(signedEvent);
// if (!publishResponse) {
// showToast('error', 'Error', 'Failed to publish resource. Please try again.');
// return;
// } else if (publishResponse?.failedRelays) {
// publishResponse?.failedRelays.map(relay => {
// showToast('warn', 'Warning', `Failed to publish to relay: ${relay}`);
// });
// }
// publishResponse?.successfulRelays.map(relay => {
// showToast('success', 'Success', `Published to relay: ${relay}`);
// })
// }
// // For images, whether included in the markdown content or not, clients SHOULD use image tags as described in NIP-58. This allows clients to display images in carousel format more easily.
// const savePaidResource = async (payload) => {
// // encrypt the content with NEXT_PUBLIC_APP_PRIV_KEY to NEXT_PUBLIC_APP_PUBLIC_KEY
// const encryptedContent = await nip04.encrypt(process.env.NEXT_PUBLIC_APP_PRIV_KEY ,process.env.NEXT_PUBLIC_APP_PUBLIC_KEY, payload.content);
// const newresourceId = uuidv4();
// const event = {
// kind: 30402,
// content: encryptedContent,
// created_at: Math.floor(Date.now() / 1000),
// tags: [
// ['title', payload.title],
// ['summary', payload.summary],
// ['t', ...topics],
// ['image', ''],
// ['d', newresourceId],
// ['location', `https://plebdevs.com/resource/${newresourceId}`],
// ['published_at', Math.floor(Date.now() / 1000).toString()],
// ['price', payload.price]
// ]
// };
// const signedEvent = await window.nostr.signEvent(event);
// const eventVerification = await verifyEvent(signedEvent);
// if (!eventVerification) {
// showToast('error', 'Error', 'Event verification failed. Please try again.');
// return;
// }
// const nAddress = nip19.naddrEncode({
// pubkey: signedEvent.pubkey,
// kind: signedEvent.kind,
// identifier: newresourceId,
// })
// console.log('nAddress:', nAddress);
// const userResponse = await axios.get(`/api/users/${user.pubkey}`)
// if (!userResponse.data) {
// showToast('error', 'Error', 'User not found', 'Please try again.');
// return;
// }
// const resourcePayload = {
// id: newresourceId,
// userId: userResponse.data.id,
// price: payload.price || 0,
// noteId: nAddress,
// }
// const response = await axios.post(`/api/resources`, resourcePayload);
// if (response.status !== 201) {
// showToast('error', 'Error', 'Failed to create resource. Please try again.');
// return;
// }
// const publishResponse = await publishAll(signedEvent);
// if (!publishResponse) {
// showToast('error', 'Error', 'Failed to publish resource. Please try again.');
// return;
// } else if (publishResponse?.failedRelays) {
// publishResponse?.failedRelays.map(relay => {
// showToast('warn', 'Warning', `Failed to publish to relay: ${relay}`);
// });
// }
// publishResponse?.successfulRelays.map(relay => {
// showToast('success', 'Success', `Published to relay: ${relay}`);
// })
// }
const handleTopicChange = (index, value) => {
const updatedTopics = topics.map((topic, i) => i === index ? value : topic);
setTopics(updatedTopics);
@ -255,39 +107,6 @@ const ResourceForm = ({ draft = null }) => {
setTopics(updatedTopics);
};
// Define custom toolbar for the editor
const customToolbar = (
<div id="toolbar">
{/* Include existing toolbar items */}
<span className="ql-formats">
<select className="ql-header" defaultValue="">
<option value="1">Heading</option>
<option value="2">Subheading</option>
<option value="">Normal</option>
</select>
</span>
<span className="ql-formats">
<button className="ql-bold"></button>
<button className="ql-italic"></button>
<button className="ql-underline"></button>
</span>
<span className="ql-formats">
<button className="ql-list" value="ordered"></button>
<button className="ql-list" value="bullet"></button>
<button className="ql-indent" value="-1"></button>
<button className="ql-indent" value="+1"></button>
</span>
<span className="ql-formats">
<button className="ql-link"></button>
<button className="ql-image"></button>
<button className="ql-video"></button> {/* This is your custom video button */}
</span>
<span className="ql-formats">
<button className="ql-clean"></button>
</span>
</div>
);
return (
<form onSubmit={handleSubmit}>
<div className="p-inputgroup flex-1">

View File

@ -1,5 +1,5 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import NDK from "@nostr-dev-kit/ndk";
import NDK, { NDKNip07Signer } from "@nostr-dev-kit/ndk";
const NDKContext = createContext(null);
@ -17,7 +17,8 @@ export const NDKProvider = ({ children }) => {
const [ndk, setNdk] = useState(null);
useEffect(() => {
const instance = new NDK({ explicitRelayUrls: relayUrls });
const nip07signer = new NDKNip07Signer();
const instance = new NDK({ explicitRelayUrls: relayUrls, signer: nip07signer });
setNdk(instance);
}, []);

View File

@ -1,43 +0,0 @@
import { createContext, useState, useEffect } from 'react';
import { SimplePool } from 'nostr-tools';
const defaultRelays = [
"wss://nos.lol/",
"wss://relay.damus.io/",
"wss://relay.snort.social/",
"wss://relay.nostr.band/",
"wss://nostr.mutinywallet.com/",
"wss://relay.mutinywallet.com/",
"wss://relay.primal.net/"
];
export const NostrContext = createContext();
export const NostrProvider = ({ children }) => {
const [pool, setPool] = useState(null);
useEffect(() => {
const newPool = new SimplePool({ verifyEvent: () => true });
setPool(newPool);
const connectRelays = async () => {
try {
await Promise.all(defaultRelays.map((url) => newPool.ensureRelay(url)));
} catch (error) {
console.error('Error connecting to relays:', error);
}
};
connectRelays();
return () => {
newPool.close(defaultRelays);
};
}, []);
return (
<NostrContext.Provider value={pool}>
{children}
</NostrContext.Provider>
);
};

View File

@ -1,15 +1,15 @@
import { useCallback, useEffect } from 'react';
import { useRouter } from 'next/router';
import { useNostr } from './useNostr';
import axios from 'axios';
import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { findKind0Fields } from "@/utils/nostr";
import { useToast } from './useToast';
import { useNDKContext } from "@/context/NDKContext";
export const useLogin = () => {
const router = useRouter();
const { showToast } = useToast();
const { fetchKind0 } = useNostr();
const ndk = useNDKContext();
// Attempt Auto Login on render
useEffect(() => {
@ -26,7 +26,8 @@ export const useLogin = () => {
window.localStorage.setItem('user', JSON.stringify(response.data));
} else if (response.status === 204) {
// User not found, create a new user
const kind0 = await fetchKind0(publicKey);
const author = await ndk.getUser({ pubkey: publicKey });
const kind0 = await author.fetchProfile();
console.log('kind0:', kind0);
@ -57,7 +58,7 @@ export const useLogin = () => {
};
autoLogin();
}, []);
}, [ndk, showToast]);
const nostrLogin = useCallback(async () => {
if (!window || !window.nostr) {
@ -77,7 +78,8 @@ export const useLogin = () => {
if (response.status === 204) {
// User not found, create a new user
const kind0 = await fetchKind0(publicKey);
const author = await ndk.getUser({ pubkey: publicKey });
const kind0 = await author.fetchProfile();
let fields = {};
if (kind0) {
@ -100,7 +102,7 @@ export const useLogin = () => {
console.error('Error during login:', error);
showToast('error', 'Login Error', error.message || 'Failed to log in');
}
}, [router, showToast, fetchKind0]);
}, [router, showToast, ndk]);
const anonymousLogin = useCallback(() => {
try {

View File

@ -1,565 +0,0 @@
import { useState, useEffect, useCallback, useContext, useRef } from 'react';
import axios from 'axios';
import { nip57, nip19 } from 'nostr-tools';
import { NostrContext } from '@/context/NostrContext';
import { lnurlEncode } from '@/utils/lnurl';
import { parseEvent } from '@/utils/nostr';
import { v4 as uuidv4 } from 'uuid';
const defaultRelays = [
"wss://nos.lol/",
"wss://relay.damus.io/",
"wss://relay.snort.social/",
"wss://relay.nostr.band/",
"wss://nostr.mutinywallet.com/",
"wss://relay.mutinywallet.com/",
"wss://relay.primal.net/"
];
const AUTHOR_PUBKEY = process.env.NEXT_PUBLIC_AUTHOR_PUBKEY;
export function useNostr() {
const pool = useContext(NostrContext);
const subscriptionQueue = useRef([]);
const lastSubscriptionTime = useRef(0);
const throttleDelay = 2000;
const processSubscriptionQueue = useCallback(() => {
if (subscriptionQueue.current.length === 0) return;
const currentTime = Date.now();
if (currentTime - lastSubscriptionTime.current < throttleDelay) {
setTimeout(processSubscriptionQueue, throttleDelay);
return;
}
const subscription = subscriptionQueue.current.shift();
subscription();
lastSubscriptionTime.current = currentTime;
setTimeout(processSubscriptionQueue, throttleDelay);
}, [throttleDelay]);
const subscribe = useCallback(
(filters, opts) => {
if (!pool) return;
const subscriptionFn = () => {
return pool.subscribeMany(defaultRelays, filters, opts);
};
subscriptionQueue.current.push(subscriptionFn);
processSubscriptionQueue();
},
[pool, processSubscriptionQueue]
);
const publish = useCallback(
async (event) => {
if (!pool) return;
try {
await Promise.any(pool.publish(defaultRelays, event));
console.log('Published event to at least one relay');
return true;
} catch (error) {
console.error('Failed to publish event:', error);
return false;
}
},
[pool]
);
const fetchSingleEvent = useCallback(
async (id) => {
try {
const event = await new Promise((resolve, reject) => {
subscribe(
[{ ids: [id] }],
{
onevent: (event) => {
console.log('Fetched event:', event);
resolve(event);
},
onerror: (error) => {
console.error('Failed to fetch event:', error);
reject(error);
},
}
);
});
return event;
} catch (error) {
console.error('Failed to fetch event:', error);
return null;
}
},
[subscribe]
);
const fetchSingleNaddrEvent = useCallback(
async (id) => {
try {
const event = await new Promise((resolve, reject) => {
subscribe(
[{ "#d": [id] }],
{
onevent: (event) => {
resolve(event);
},
onerror: (error) => {
console.error('Failed to fetch event:', error);
reject(error);
},
}
);
});
return event;
} catch (error) {
console.error('Failed to fetch event:', error);
return null;
}
},
[subscribe]
);
const querySyncQueue = useRef([]);
const lastQuerySyncTime = useRef(0);
const processQuerySyncQueue = useCallback(() => {
if (querySyncQueue.current.length === 0) return;
const currentTime = Date.now();
if (currentTime - lastQuerySyncTime.current < throttleDelay) {
setTimeout(processQuerySyncQueue, throttleDelay);
return;
}
const querySync = querySyncQueue.current.shift();
querySync();
lastQuerySyncTime.current = currentTime;
setTimeout(processQuerySyncQueue, throttleDelay);
}, [throttleDelay]);
const fetchZapsForParamaterizedEvent = useCallback(
async (kind, id, d) => {
try {
const filters = { kinds: [9735], '#a': [`${kind}:${id}:${d}`] };
const zaps = await pool.querySync(defaultRelays, filters);
return zaps;
} catch (error) {
console.error('Failed to fetch zaps for event:', error);
return [];
}
},
[pool]
);
const fetchZapsForNonParameterizedEvent = useCallback(
async (id) => {
try {
const filters = { kinds: [9735], '#e': [id] };
const zaps = await pool.querySync(defaultRelays, filters);
return zaps;
} catch (error) {
console.error('Failed to fetch zaps for event:', error);
return [];
}
},
[pool]
);
const fetchZapsForEvent = useCallback(
async (event) => {
const querySyncFn = async () => {
try {
const parameterizedZaps = await fetchZapsForParamaterizedEvent(event.kind, event.id, event.d);
const nonParameterizedZaps = await fetchZapsForNonParameterizedEvent(event.id);
return [...parameterizedZaps, ...nonParameterizedZaps];
} catch (error) {
console.error('Failed to fetch zaps for event:', error);
return [];
}
};
return new Promise((resolve) => {
querySyncQueue.current.push(async () => {
const zaps = await querySyncFn();
resolve(zaps);
});
processQuerySyncQueue();
});
},
[fetchZapsForParamaterizedEvent, fetchZapsForNonParameterizedEvent, processQuerySyncQueue]
);
const fetchZapsForEvents = useCallback(
async (events) => {
const querySyncFn = async () => {
try {
// Collect all #a and #e tag values from the list of events
let aTags = [];
let aTagsAlt = [];
let eTags = [];
events.forEach(event => {
aTags.push(`${event.kind}:${event.id}:${event.d}`);
aTagsAlt.push(`${event.kind}:${event.pubkey}:${event.d}`);
eTags.push(event.id);
});
// Create filters for batch querying
const filterA = { kinds: [9735], '#a': aTags };
const filterE = { kinds: [9735], '#e': eTags };
const filterAAlt = { kinds: [9735], '#a': aTagsAlt };
// Perform batch queries
// const [zapsA, zapsE] = await Promise.all([
// pool.querySync(defaultRelays, filterA),
// pool.querySync(defaultRelays, filterE)
// ]);
let allZaps = []
await new Promise((resolve) => pool.subscribeMany(defaultRelays, [filterA, filterE, filterAAlt], {
onerror: (error) => {
console.error('Failed to fetch zaps for events:', error);
resolve([]);
},
onevent: (event) => {
allZaps.push(event);
},
oneose: () => {
resolve(allZaps);
}
}))
// remove any duplicates
allZaps = allZaps.filter((zap, index, self) => index === self.findIndex((t) => (
t.id === zap.id
)))
return allZaps;
} catch (error) {
console.error('Failed to fetch zaps for events:', error);
return [];
}
};
return new Promise((resolve) => {
querySyncQueue.current.push(async () => {
const zaps = await querySyncFn();
resolve(zaps);
});
processQuerySyncQueue();
});
},
[pool, processQuerySyncQueue]
);
const fetchKind0 = useCallback(
async (publicKey) => {
return new Promise((resolve) => {
const timeout = setTimeout(() => {
resolve(null); // Resolve with null if no event is received within the timeout
}, 10000); // 10 seconds timeout
subscribe(
[{ authors: [publicKey], kinds: [0] }],
{
onevent: (event) => {
clearTimeout(timeout);
resolve(JSON.parse(event.content));
},
onerror: (error) => {
clearTimeout(timeout);
console.error('Error fetching kind 0:', error);
resolve(null);
},
}
);
});
},
[subscribe]
);
const zapEvent = useCallback(
async (event, amount, comment) => {
const kind0 = await fetchKind0(event.pubkey);
if (kind0.length === 0) {
console.error('Error fetching kind0');
return;
}
if (kind0.lud16) {
const lud16Username = kind0.lud16.split('@')[0];
const lud16Domain = kind0.lud16.split('@')[1];
const lud16Url = `https://${lud16Domain}/.well-known/lnurlp/${lud16Username}`;
try {
const response = await axios.get(lud16Url);
if (response.data.allowsNostr) {
// const zapReq = nip57.makeZapRequest({
// profile: event.pubkey,
// event: event.id,
// amount: amount,
// relays: defaultRelays,
// comment: comment ? comment : 'Plebdevs Zap',
// });
const user = window.localStorage.getItem('user');
const pubkey = JSON.parse(user).pubkey;
const lnurl = lnurlEncode(lud16Url)
console.log('lnurl:', lnurl);
console.log('pubkey:', pubkey);
const zapReq = {
kind: 9734,
content: "",
tags: [
["relays", defaultRelays.join(",")],
["amount", amount.toString()],
// ["lnurl", lnurl],
["e", event.id],
["p", event.pubkey],
["a", `${event.kind}:${event.pubkey}:${event.d}`],
],
created_at: Math.floor(Date.now() / 1000)
}
console.log('zapRequest:', zapReq);
const signedEvent = await window?.nostr?.signEvent(zapReq);
console.log('signedEvent:', signedEvent);
const callbackUrl = response.data.callback;
const zapRequestAPICall = `${callbackUrl}?amount=${amount}&nostr=${encodeURI(
JSON.stringify(signedEvent)
)}`;
const invoiceResponse = await axios.get(zapRequestAPICall);
if (invoiceResponse?.data?.pr) {
const invoice = invoiceResponse.data.pr;
const enabled = await window?.webln?.enable();
console.log('webln enabled:', enabled);
const payInvoiceResponse = await window?.webln?.sendPayment(invoice);
console.log('payInvoiceResponse:', payInvoiceResponse);
} else {
console.error('Error fetching invoice');
// showToast('error', 'Error', 'Error fetching invoice');
}
}
} catch (error) {
console.error('Error fetching lud16 data:', error);
}
} else if (profile.lud06) {
// handle lnurlpay
} else {
showToast('error', 'Error', 'User has no Lightning Address or LNURL');
}
},
[fetchKind0]
);
const fetchResources = useCallback(async () => {
const filter = [{ kinds: [30023, 30402], authors: [AUTHOR_PUBKEY] }];
const hasRequiredTags = (tags) => {
const hasPlebDevs = tags.some(([tag, value]) => tag === "t" && value === "plebdevs");
const hasResource = tags.some(([tag, value]) => tag === "t" && value === "resource");
return hasPlebDevs && hasResource;
};
return new Promise((resolve, reject) => {
let resources = [];
const subscription = subscribe(
filter,
{
onevent: (event) => {
if (hasRequiredTags(event.tags)) {
resources.push(event);
}
},
onerror: (error) => {
console.error('Error fetching resources:', error);
// Don't resolve here, just log the error
},
onclose: () => {
// Don't resolve here either
},
},
2000 // Adjust the timeout value as needed
);
// Set a timeout to resolve the promise after collecting events
setTimeout(() => {
subscription?.close();
resolve(resources);
}, 2000); // Adjust the timeout value as needed
});
}, [subscribe]);
const fetchWorkshops = useCallback(async () => {
const filter = [{ kinds: [30023, 30402], authors: [AUTHOR_PUBKEY] }];
const hasRequiredTags = (tags) => {
const hasPlebDevs = tags.some(([tag, value]) => tag === "t" && value === "plebdevs");
const hasWorkshop = tags.some(([tag, value]) => tag === "t" && value === "workshop");
return hasPlebDevs && hasWorkshop;
};
return new Promise((resolve, reject) => {
let workshops = [];
const subscription = subscribe(
filter,
{
onevent: (event) => {
if (hasRequiredTags(event.tags)) {
workshops.push(event);
}
},
onerror: (error) => {
console.error('Error fetching workshops:', error);
// Don't resolve here, just log the error
},
onclose: () => {
// Don't resolve here either
},
},
2000 // Adjust the timeout value as needed
);
setTimeout(() => {
subscription?.close();
resolve(workshops);
}, 2000); // Adjust the timeout value as needed
});
}, [subscribe]);
const fetchCourses = useCallback(async () => {
const filter = [{ kinds: [30004], authors: [AUTHOR_PUBKEY] }];
// Do we need required tags for courses? community instead?
// const hasRequiredTags = (tags) => {
// const hasPlebDevs = tags.some(([tag, value]) => tag === "t" && value === "plebdevs");
// const hasCourse = tags.some(([tag, value]) => tag === "t" && value === "course");
// return hasPlebDevs && hasCourse;
// };
return new Promise((resolve, reject) => {
let courses = [];
const subscription = subscribe(
filter,
{
onevent: (event) => {
// if (hasRequiredTags(event.tags)) {
// courses.push(event);
// }
courses.push(event);
},
onerror: (error) => {
console.error('Error fetching courses:', error);
// Don't resolve here, just log the error
},
onclose: () => {
// Don't resolve here either
},
},
2000 // Adjust the timeout value as needed
);
setTimeout(() => {
subscription?.close();
resolve(courses);
}, 2000); // Adjust the timeout value as needed
});
}, [subscribe]);
const publishResource = useCallback(
async (resourceEvent) => {
const published = await publish(resourceEvent);
if (published) {
const { id, kind, pubkey, content, title, summary, image, published_at, d, topics } = parseEvent(resourceEvent);
const user = window.localStorage.getItem('user');
const userId = JSON.parse(user).id;
const payload = {
id: uuidv4(),
user: {
connect: { id: userId } // This is the correct way to connect to an existing user
},
noteId: id
};
if (payload && payload.user) {
try {
const response = await axios.post('/api/resources', payload);
if (response.status === 201) {
return true;
}
} catch (error) {
console.error('Error creating resource:', error);
return false;
}
}
}
return false;
},
[publish]
);
const publishCourse = useCallback(
async (courseEvent) => {
const published = await publish(courseEvent);
if (published) {
const user = window.localStorage.getItem('user');
const pubkey = JSON.parse(user).pubkey;
const payload = {
title: courseEvent.title,
summary: courseEvent.summary,
type: 'course',
content: courseEvent.content,
image: courseEvent.image,
user: pubkey,
topics: [...courseEvent.topics.map(topic => topic.trim().toLowerCase()), 'plebdevs', 'course']
};
if (payload && payload.user) {
try {
const response = await axios.post('/api/courses', payload);
if (response.status === 201) {
try {
const deleteResponse = await axios.delete(`/api/drafts/${courseEvent.id}`);
if (deleteResponse.status === 204) {
return true;
}
} catch (error) {
console.error('Error deleting draft:', error);
return false;
}
}
} catch (error) {
console.error('Error creating course:', error);
return false;
}
}
}
return false;
},
[publish]
);
return { subscribe, publish, fetchSingleEvent, fetchSingleNaddrEvent, fetchZapsForEvent, fetchKind0, fetchResources, fetchWorkshops, fetchCourses, zapEvent, fetchZapsForEvents, publishResource, publishCourse };
}

View File

@ -8,7 +8,6 @@ import 'primereact/resources/themes/lara-dark-indigo/theme.css';
import "@uiw/react-md-editor/markdown-editor.css";
import "@uiw/react-markdown-preview/markdown.css";
import Sidebar from '@/components/sidebar/Sidebar';
import { NostrProvider } from '@/context/NostrContext';
import { NDKProvider } from '@/context/NDKContext';
import {
QueryClient,
@ -22,26 +21,24 @@ export default function MyApp({
}) {
return (
<PrimeReactProvider>
<NostrProvider>
<NDKProvider>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Layout>
<div className="flex flex-col min-h-screen">
<Navbar />
{/* <div className='flex'> */}
{/* <Sidebar /> */}
{/* <div className='max-w-[100vw] pl-[15vw]'> */}
<div className='max-w-[100vw]'>
<Component {...pageProps} />
</div>
{/* </div> */}
<NDKProvider>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Layout>
<div className="flex flex-col min-h-screen">
<Navbar />
{/* <div className='flex'> */}
{/* <Sidebar /> */}
{/* <div className='max-w-[100vw] pl-[15vw]'> */}
<div className='max-w-[100vw]'>
<Component {...pageProps} />
</div>
</Layout>
</ToastProvider>
</QueryClientProvider>
</NDKProvider>
</NostrProvider>
{/* </div> */}
</div>
</Layout>
</ToastProvider>
</QueryClientProvider>
</NDKProvider>
</PrimeReactProvider>
);
}

View File

@ -1,15 +1,16 @@
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { useRouter } from 'next/router';
import { useNostr } from '@/hooks/useNostr';
import { parseEvent, findKind0Fields, hexToNpub } from '@/utils/nostr';
import { verifyEvent, nip19, nip04 } from 'nostr-tools';
import { hexToNpub } from '@/utils/nostr';
import { nip19, nip04 } from 'nostr-tools';
import { v4 as uuidv4 } from 'uuid';
import { useLocalStorageWithEffect } from '@/hooks/useLocalStorage';
import { useImageProxy } from '@/hooks/useImageProxy';
import { Button } from 'primereact/button';
import { useToast } from '@/hooks/useToast';
import { Tag } from 'primereact/tag';
import { useNDKContext } from '@/context/NDKContext';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import Image from 'next/image';
import useResponsiveImageDimensions from '@/hooks/useResponsiveImageDimensions';
import 'primeicons/primeicons.css';
@ -21,20 +22,33 @@ const MDDisplay = dynamic(
}
);
export default function Details() {
function validateEvent(event) {
if (typeof event.kind !== "number") return "Invalid kind";
if (typeof event.content !== "string") return "Invalid content";
if (typeof event.created_at !== "number") return "Invalid created_at";
if (typeof event.pubkey !== "string") return "Invalid pubkey";
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return "Invalid pubkey format";
if (!Array.isArray(event.tags)) return "Invalid tags";
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i];
if (!Array.isArray(tag)) return "Invalid tag structure";
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === "object") return "Invalid tag value";
}
}
return true;
}
export default function Draft() {
const [draft, setDraft] = useState(null);
const { returnImageProxy } = useImageProxy();
const { publishCourse, publishResource, fetchSingleEvent } = useNostr();
const [user] = useLocalStorageWithEffect('user', {});
const { width, height } = useResponsiveImageDimensions();
const router = useRouter();
const { showToast } = useToast();
const ndk = useNDKContext();
useEffect(() => {
if (router.isReady) {
@ -52,32 +66,90 @@ export default function Details() {
}, [router.isReady, router.query]);
const handleSubmit = async () => {
if (draft) {
const { unsignedEvent, type } = await buildEvent(draft);
try {
if (draft) {
const { unsignedEvent, type } = await buildEvent(draft);
if (unsignedEvent) {
const published = await publishEvent(unsignedEvent, type);
console.log('published:', published);
// if successful, delete the draft, redirect to profile
if (published) {
axios.delete(`/api/drafts/${draft.id}`)
.then(res => {
if (res.status === 204) {
showToast('success', 'Success', 'Draft deleted successfully.');
router.push(`/profile`);
} else {
showToast('error', 'Error', 'Failed to delete draft.');
}
})
.catch(err => {
console.error(err);
});
const validationResult = validateEvent(unsignedEvent);
if (validationResult !== true) {
console.error('Invalid event:', validationResult);
showToast('error', 'Error', `Invalid event: ${validationResult}`);
return;
}
console.log('unsignedEvent:', unsignedEvent.validate());
console.log('unsignedEvent validation:', validationResult);
if (unsignedEvent) {
const published = await unsignedEvent.publish();
const saved = await handlePostResource(unsignedEvent);
// if successful, delete the draft, redirect to profile
if (published && saved) {
axios.delete(`/api/drafts/${draft.id}`)
.then(res => {
if (res.status === 204) {
showToast('success', 'Success', 'Draft deleted successfully.');
router.push(`/profile`);
} else {
showToast('error', 'Error', 'Failed to delete draft.');
}
})
.catch(err => {
console.error(err);
});
}
} else {
showToast('error', 'Error', 'Failed to broadcast resource. Please try again.');
}
} else {
showToast('error', 'Error', 'Failed to broadcast resource. Please try again.');
}
} catch (err) {
console.error(err);
showToast('error', 'Failed to publish resource.', err.message);
}
}
};
const handlePostResource = async (resource) => {
console.log('resourceeeeee:', resource.tags);
const dTag = resource.tags.find(tag => tag[0] === 'd')[1];
let price
try {
price = resource.tags.find(tag => tag[0] === 'price')[1];
} catch (err) {
console.error(err);
price = 0;
}
const nAddress = nip19.naddrEncode({
pubkey: resource.pubkey,
kind: resource.kind,
identifier: dTag,
});
const userResponse = await axios.get(`/api/users/${user.pubkey}`);
if (!userResponse.data) {
showToast('error', 'Error', 'User not found', 'Please try again.');
return;
}
const payload = {
id: dTag,
userId: userResponse.data.id,
price: Number(price),
noteId: nAddress,
};
const response = await axios.post(`/api/resources`, payload);
if (response.status !== 201) {
showToast('error', 'Error', 'Failed to create resource. Please try again.');
return;
}
return response.data;
};
const handleDelete = async () => {
if (draft) {
@ -94,107 +166,38 @@ export default function Details() {
console.error(err);
});
}
}
const publishEvent = async (event, type) => {
const dTag = event.tags.find(tag => tag[0] === 'd')[1];
const signedEvent = await window.nostr.signEvent(event);
const eventVerification = await verifyEvent(signedEvent);
if (!eventVerification) {
showToast('error', 'Error', 'Event verification failed. Please try again.');
return;
}
const nAddress = nip19.naddrEncode({
pubkey: signedEvent.pubkey,
kind: signedEvent.kind,
identifier: dTag,
})
const userResponse = await axios.get(`/api/users/${user.pubkey}`)
if (!userResponse.data) {
showToast('error', 'Error', 'User not found', 'Please try again.');
return;
}
const payload = {
id: dTag,
userId: userResponse.data.id,
price: Number(draft.price) || 0,
noteId: nAddress,
}
const response = await axios.post(`/api/resources`, payload);
if (response.status !== 201) {
showToast('error', 'Error', 'Failed to create resource. Please try again.');
return;
}
let published;
console.log('type:', type);
if (type === 'resource' || type === 'workshop') {
published = await publishResource(signedEvent);
} else if (type === 'course') {
published = await publishCourse(signedEvent);
}
if (published) {
// check if the event is published
const publishedEvent = await fetchSingleEvent(signedEvent.id);
if (publishedEvent) {
// show success message
showToast('success', 'Success', `${type} published successfully.`);
// delete the draft
await axios.delete(`/api/drafts/${draft.id}`)
.then(res => {
if (res.status === 204) {
showToast('success', 'Success', 'Draft deleted successfully.');
router.push(`/profile`);
} else {
showToast('error', 'Error', 'Failed to delete draft.');
}
})
.catch(err => {
console.error(err);
});
}
}
}
};
const buildEvent = async (draft) => {
const NewDTag = uuidv4();
let event = {};
const event = new NDKEvent(ndk);
let type;
let encryptedContent;
console.log('Draft:', draft);
console.log('NewDTag:', NewDTag);
switch (draft?.type) {
case 'resource':
if (draft?.price) {
// encrypt the content with NEXT_PUBLIC_APP_PRIV_KEY to NEXT_PUBLIC_APP_PUBLIC_KEY
encryptedContent = await nip04.encrypt(process.env.NEXT_PUBLIC_APP_PRIV_KEY, process.env.NEXT_PUBLIC_APP_PUBLIC_KEY, draft.content);
}
event = {
kind: draft?.price ? 30402 : 30023, // Determine kind based on if price is present
content: draft?.price ? encryptedContent : draft.content,
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', NewDTag],
['title', draft.title],
['summary', draft.summary],
['image', draft.image],
...draft.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
// Include price and location tags only if price is present
...(draft?.price ? [['price', draft.price.toString()], ['location', `https://plebdevs.com/details/${draft.id}`]] : []),
]
};
event.kind = draft?.price ? 30402 : 30023; // Determine kind based on if price is present
event.content = draft?.price ? encryptedContent : draft.content;
event.created_at = Math.floor(Date.now() / 1000);
event.pubkey = user.pubkey;
event.tags = [
['d', NewDTag],
['title', draft.title],
['summary', draft.summary],
['image', draft.image],
...draft.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
...(draft?.price ? [['price', draft.price.toString()], ['location', `https://plebdevs.com/details/${draft.id}`]] : []),
];
type = 'resource';
break;
case 'workshop':
@ -202,40 +205,40 @@ export default function Details() {
// encrypt the content with NEXT_PUBLIC_APP_PRIV_KEY to NEXT_PUBLIC_APP_PUBLIC_KEY
encryptedContent = await nip04.encrypt(process.env.NEXT_PUBLIC_APP_PRIV_KEY, process.env.NEXT_PUBLIC_APP_PUBLIC_KEY, draft.content);
}
event = {
kind: draft?.price ? 30402 : 30023,
content: draft?.price ? encryptedContent : draft.content,
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', NewDTag],
['title', draft.title],
['summary', draft.summary],
['image', draft.image],
...draft.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
]
};
event.kind = draft?.price ? 30402 : 30023;
event.content = draft?.price ? encryptedContent : draft.content;
event.created_at = Math.floor(Date.now() / 1000);
event.pubkey = user.pubkey;
event.tags = [
['d', NewDTag],
['title', draft.title],
['summary', draft.summary],
['image', draft.image],
...draft.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
];
type = 'workshop';
break;
case 'course':
event = {
kind: 30023,
content: draft.content,
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', NewDTag],
['title', draft.title],
['summary', draft.summary],
['image', draft.image],
...draft.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
]
};
event.kind = 30023;
event.content = draft.content;
event.created_at = Math.floor(Date.now() / 1000);
event.pubkey = user.pubkey;
event.tags = [
['d', NewDTag],
['title', draft.title],
['summary', draft.summary],
['image', draft.image],
...draft.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
];
type = 'course';
break;
default:
event = null;
type = 'unknown';
return null;
}
return { unsignedEvent: event, type };
@ -244,7 +247,7 @@ export default function Details() {
return (
<div className='w-full px-24 pt-12 mx-auto mt-4 max-tab:px-0 max-mob:px-0 max-tab:pt-2 max-mob:pt-2'>
<div className='w-full flex flex-row justify-between max-tab:flex-col max-mob:flex-col'>
{/* <i className='pi pi-arrow-left pl-8 cursor-pointer hover:opacity-75 max-tab:pl-2 max-mob:pl-2' onClick={() => router.push('/')} /> */}
<i className='pi pi-arrow-left pl-8 cursor-pointer hover:opacity-75 max-tab:pl-2 max-mob:pl-2' onClick={() => router.push('/')} />
<div className='w-[75vw] mx-auto flex flex-row items-start justify-between max-tab:flex-col max-mob:flex-col max-tab:w-[95vw] max-mob:w-[95vw]'>
<div className='flex flex-col items-start max-w-[45vw] max-tab:max-w-[100vw] max-mob:max-w-[100vw]'>
<div className='pt-2 flex flex-row justify-start w-full'>
@ -254,8 +257,7 @@ export default function Details() {
return (
<Tag className='mr-2 text-white' key={index} value={topic}></Tag>
)
})
}
})}
</div>
<h1 className='text-4xl mt-6'>{draft?.title}</h1>
<p className='text-xl mt-6'>{draft?.summary}</p>
@ -271,7 +273,7 @@ export default function Details() {
<p className='text-lg'>
Created by{' '}
<a href={`https://nostr.com/${hexToNpub(user?.pubkey)}`} rel='noreferrer noopener' target='_blank' className='text-blue-500 hover:underline'>
{user?.username || user?.pubkey.slice(0, 10)}{'... '}
{user?.username || user?.name || user?.pubkey.slice(0, 10)}{'... '}
</a>
</p>
)}
@ -309,4 +311,4 @@ export default function Details() {
</div>
</div>
);
}
}

View File

@ -15,6 +15,8 @@ const Profile = () => {
const { returnImageProxy } = useImageProxy();
const menu = useRef(null);
console.log('user:', user);
const purchases = [];
const menuItems = [