From a255f1e5b0afc16f1c552af944713dafd1ec2503 Mon Sep 17 00:00:00 2001 From: austinkelsay <austinkelsay@yahoo.com> Date: Mon, 22 Apr 2024 19:09:46 -0500 Subject: [PATCH] Working on zaps --- package-lock.json | 6 + package.json | 1 + .../carousels/templates/CourseTemplate.js | 120 +++++++++--------- .../carousels/templates/ResourceTemplate.js | 4 +- .../carousels/templates/WorkshopTemplate.js | 4 +- src/components/zaps/ZapDisplay.js | 19 +++ src/components/zaps/ZapForm.js | 49 +++++++ src/hooks/useNostr.js | 83 ++++++++---- src/utils/lnurl.js | 12 ++ src/utils/nostr.js | 4 + 10 files changed, 211 insertions(+), 91 deletions(-) create mode 100644 src/components/zaps/ZapDisplay.js create mode 100644 src/components/zaps/ZapForm.js create mode 100644 src/utils/lnurl.js diff --git a/package-lock.json b/package-lock.json index 4c9c346..a6b165c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@prisma/client": "^5.9.1", "@reduxjs/toolkit": "^2.1.0", "axios": "^1.6.7", + "bech32": "^2.0.0", "classnames": "^2.5.1", "light-bolt11-decoder": "^3.1.1", "next": "14.0.4", @@ -1378,6 +1379,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==" + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", diff --git a/package.json b/package.json index b71b5df..049b7b5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@prisma/client": "^5.9.1", "@reduxjs/toolkit": "^2.1.0", "axios": "^1.6.7", + "bech32": "^2.0.0", "classnames": "^2.5.1", "light-bolt11-decoder": "^3.1.1", "next": "14.0.4", diff --git a/src/components/content/carousels/templates/CourseTemplate.js b/src/components/content/carousels/templates/CourseTemplate.js index 1b6fdc6..22a7110 100644 --- a/src/components/content/carousels/templates/CourseTemplate.js +++ b/src/components/content/carousels/templates/CourseTemplate.js @@ -5,73 +5,71 @@ import { formatTimestampToHowLongAgo } from "@/utils/time"; import { useImageProxy } from "@/hooks/useImageProxy"; import { useNostr } from "@/hooks/useNostr"; import { getSatAmountFromInvoice } from "@/utils/lightning"; +import ZapDisplay from "@/components/zaps/ZapDisplay"; const CourseTemplate = (course) => { - const [zaps, setZaps] = useState([]); - const [zapAmount, setZapAmount] = useState(null); - const router = useRouter(); - const { returnImageProxy } = useImageProxy(); - const { fetchZapsForEvent } = useNostr(); + const [zaps, setZaps] = useState([]); + const [zapAmount, setZapAmount] = useState(null); + const router = useRouter(); + const { returnImageProxy } = useImageProxy(); + const { fetchZapsForEvent } = useNostr(); - useEffect(() => { - const fetchZaps = async () => { - try { - const zaps = await fetchZapsForEvent(course.id); - setZaps(zaps); - } catch (error) { - console.error("Error fetching zaps:", error); - } - }; - fetchZaps(); - }, [fetchZapsForEvent, course]); + useEffect(() => { + const fetchZaps = async () => { + try { + const zaps = await fetchZapsForEvent(course); + // console.log('zaps:', zaps); + if (zaps.length > 0) { + let total = 0; + zaps.map((zap) => { + const bolt11Tag = zap.tags.find((tag) => tag[0] === "bolt11"); + const invoice = bolt11Tag ? bolt11Tag[1] : null; + if (invoice) { + const amount = getSatAmountFromInvoice(invoice); + total += amount; + } + }); + setZapAmount(total); + } + } catch (error) { + console.error("Error fetching zaps:", error); + } + }; + fetchZaps(); + }, [fetchZapsForEvent, course]); - useEffect(() => { - if (zaps.length > 0) { - zaps.map((zap) => { - const bolt11Tag = zap.tags.find((tag) => tag[0] === "bolt11"); - const invoice = bolt11Tag ? bolt11Tag[1] : null; - if (invoice) { - const amount = getSatAmountFromInvoice(invoice); - setZapAmount(zapAmount + amount); - } - }); - } - }, [zaps]); - - return ( - <div - className="flex flex-col items-center mx-auto px-4 mt-8 rounded-md" - > - <div - onClick={() => router.push(`/details/${course.id}`)} - className="relative w-full h-0 hover:opacity-80 transition-opacity duration-300" - style={{ paddingBottom: "56.25%"}} + return ( + <div + className="flex flex-col items-center mx-auto px-4 mt-8 rounded-md" > - <Image - alt="course thumbnail" - src={returnImageProxy(course.image)} - quality={100} - layout="fill" - objectFit="cover" - className="rounded-md" - /> - </div> - <div className="flex flex-col justify-start w-full mt-4"> - <h4 className="mb-1 font-bold text-lg font-blinker line-clamp-2"> - {course.title} - </h4> - <p className="text-sm text-gray-500 line-clamp-2">{course.summary}</p> - <div className="flex flex-row justify-between items-center mt-2"> - <p className="text-xs text-gray-400"> - {formatTimestampToHowLongAgo(course.published_at)} - </p> - <p className="text-xs cursor-pointer"> - <i className="pi pi-bolt text-yellow-300"></i> {zapAmount} - </p> + <div + onClick={() => router.push(`/details/${course.id}`)} + className="relative w-full h-0 hover:opacity-80 transition-opacity duration-300 cursor-pointer" + style={{ paddingBottom: "56.25%" }} + > + <Image + alt="course thumbnail" + src={returnImageProxy(course.image)} + quality={100} + layout="fill" + objectFit="cover" + className="rounded-md" + /> + </div> + <div className="flex flex-col justify-start w-full mt-4"> + <h4 className="mb-1 font-bold text-lg font-blinker line-clamp-2"> + {course.title} + </h4> + <p className="text-sm text-gray-500 line-clamp-2">{course.summary}</p> + <div className="flex flex-row justify-between items-center mt-2"> + <p className="text-xs text-gray-400"> + {formatTimestampToHowLongAgo(course.published_at)} + </p> + <ZapDisplay zapAmount={zapAmount} event={course} /> + </div> + </div> </div> - </div> - </div> - ); + ); }; export default CourseTemplate; \ No newline at end of file diff --git a/src/components/content/carousels/templates/ResourceTemplate.js b/src/components/content/carousels/templates/ResourceTemplate.js index acac2e4..e21a3fc 100644 --- a/src/components/content/carousels/templates/ResourceTemplate.js +++ b/src/components/content/carousels/templates/ResourceTemplate.js @@ -16,7 +16,7 @@ const ResourceTemplate = (resource) => { useEffect(() => { const fetchZaps = async () => { try { - const zaps = await fetchZapsForEvent(resource.id); + const zaps = await fetchZapsForEvent(resource); setZaps(zaps); } catch (error) { console.error("Error fetching zaps:", error); @@ -45,7 +45,7 @@ const ResourceTemplate = (resource) => { {/* Wrap the image in a div with a relative class with a padding-bottom of 56.25% representing the aspect ratio of 16:9 */} <div onClick={() => router.push(`/details/${resource.id}`)} - className="relative w-full h-0 hover:opacity-80 transition-opacity duration-300" + className="relative w-full h-0 hover:opacity-80 transition-opacity duration-300 cursor-pointer" style={{ paddingBottom: "56.25%"}} > <Image diff --git a/src/components/content/carousels/templates/WorkshopTemplate.js b/src/components/content/carousels/templates/WorkshopTemplate.js index 7946b19..01d7d10 100644 --- a/src/components/content/carousels/templates/WorkshopTemplate.js +++ b/src/components/content/carousels/templates/WorkshopTemplate.js @@ -16,7 +16,7 @@ const WorkshopTemplate = (workshop) => { useEffect(() => { const fetchZaps = async () => { try { - const zaps = await fetchZapsForEvent(workshop.id); + const zaps = await fetchZapsForEvent(workshop); setZaps(zaps); } catch (error) { console.error("Error fetching zaps:", error); @@ -44,7 +44,7 @@ const WorkshopTemplate = (workshop) => { > <div onClick={() => router.push(`/details/${workshop.id}`)} - className="relative w-full h-0 hover:opacity-80 transition-opacity duration-300" + className="relative w-full h-0 hover:opacity-80 transition-opacity duration-300 cursor-pointer" style={{ paddingBottom: "56.25%"}} > <Image diff --git a/src/components/zaps/ZapDisplay.js b/src/components/zaps/ZapDisplay.js new file mode 100644 index 0000000..3ea2829 --- /dev/null +++ b/src/components/zaps/ZapDisplay.js @@ -0,0 +1,19 @@ +import React, { useRef } from 'react'; +import { OverlayPanel } from 'primereact/overlaypanel'; +import ZapForm from './ZapForm'; + +const ZapDisplay = ({ zapAmount, event }) => { + const op = useRef(null); + return ( + <> + <p className="text-xs cursor-pointer" onClick={(e) => op.current.toggle(e)}> + <i className="pi pi-bolt text-yellow-300"></i> {zapAmount} + </p> + <OverlayPanel ref={op}> + <ZapForm event={event} /> + </OverlayPanel> + </> + ) +} + +export default ZapDisplay; \ No newline at end of file diff --git a/src/components/zaps/ZapForm.js b/src/components/zaps/ZapForm.js new file mode 100644 index 0000000..4c915f3 --- /dev/null +++ b/src/components/zaps/ZapForm.js @@ -0,0 +1,49 @@ +import React, { useState } from "react"; +import { Button } from 'primereact/button'; +import { InputText } from 'primereact/inputtext'; +import { InputTextarea } from 'primereact/inputtextarea'; +import { useNostr } from "@/hooks/useNostr"; + +const ZapForm = ({event}) => { + const [zapAmount, setZapAmount] = useState(0); + const [comment, setComment] = useState(""); + + const { zapEvent } = useNostr(); + + const handleZapButton = (amount) => { + setZapAmount(amount); + }; + + const handleCustomAmountChange = (event) => { + setZapAmount(event.target.value); + }; + + const handleCommentChange = (event) => { + setComment(event.target.value); + }; + + const handleSubmit = async () => { + const millisatAmount = zapAmount * 1000; + const response = await zapEvent(event, millisatAmount, comment); + + console.log('zap response:', response); + }; + + return ( + <div className="flex flex-col"> + <div className="flex flex-row justify-start"> + {[1, 10, 21, 100, 500, 1000].map(amount => ( + <Button key={amount} label={amount.toString()} icon="pi pi-bolt" severity="success" + rounded className="mr-2" onClick={() => handleZapButton(amount)} /> + ))} + </div> + <div className="flex flex-row w-[100%] justify-between my-4"> + <InputText placeholder="Custom Amount" value={zapAmount} onChange={handleCustomAmountChange} /> + </div> + <InputTextarea rows={5} placeholder="Message" value={comment} onChange={handleCommentChange} /> + <Button label="Zap" icon="pi pi-bolt" severity="success" className="mt-4" onClick={handleSubmit} /> + </div> + ); +}; + +export default ZapForm; diff --git a/src/hooks/useNostr.js b/src/hooks/useNostr.js index 3fcf945..38e8f8e 100644 --- a/src/hooks/useNostr.js +++ b/src/hooks/useNostr.js @@ -1,7 +1,8 @@ import { useState, useEffect, useCallback, useContext } from 'react'; import axios from 'axios'; -import { nip57 } from 'nostr-tools'; +import { nip57, nip19 } from 'nostr-tools'; import { NostrContext } from '@/context/NostrContext'; +import { lnurlEncode } from '@/utils/lnurl'; const defaultRelays = [ "wss://nos.lol/", @@ -11,19 +12,19 @@ const defaultRelays = [ "wss://nostr.mutinywallet.com/", "wss://relay.mutinywallet.com/", "wss://relay.primal.net/" - ]; +]; export function useNostr() { const pool = useContext(NostrContext); const subscribe = useCallback( (filters, opts) => { - if (!pool) return; - - return pool.subscribeMany(defaultRelays, filters, opts); + if (!pool) return; + + return pool.subscribeMany(defaultRelays, filters, opts); }, [pool] - ); + ); const publish = useCallback( async (event) => { @@ -55,11 +56,16 @@ export function useNostr() { ); const fetchZapsForEvent = useCallback( - async (id) => { + async (event) => { try { - const filter = { kinds: [9735], '#e': [id] }; - const zaps = await pool.querySync(defaultRelays, filter); - console.log('zaps:', zaps); + let zaps = []; + const paramaterizedFilter = { kinds: [9735], '#a': [`${event.kind}:${event.id}:${event.d}`] }; + const paramaterizedZaps = await pool.querySync(defaultRelays, paramaterizedFilter); + console.log('paramaterizedZaps:', paramaterizedZaps); + const filter = { kinds: [9735], '#e': [event.id] }; + const zapsForEvent = await pool.querySync(defaultRelays, filter); + console.log('zapsForEvent:', zapsForEvent); + zaps = zaps.concat(paramaterizedZaps, zapsForEvent); return zaps; } catch (error) { console.error('Failed to fetch zaps for event:', error); @@ -84,7 +90,7 @@ export function useNostr() { ); const zapEvent = useCallback( - async (event) => { + async (event, amount, comment) => { const kind0 = await fetchKind0(event.pubkey); if (kind0.length === 0) { @@ -104,16 +110,41 @@ export function useNostr() { const zapReq = nip57.makeZapRequest({ profile: event.pubkey, event: event.id, - amount: 1000, + amount: amount, relays: defaultRelays, - comment: 'Plebdevs Zap', + comment: comment ? comment : 'Plebdevs Zap', }); - console.log('zapReq:', zapReq); + 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 zapRequest = { + // kind: 9734, + // content: "", + // tags: [ + // ["relays", defaultRelays[4], defaultRelays[5]], + // ["amount", amount.toString()], + // // ["lnurl", lnurl], + // ["e", event.id], + // ["p", event.pubkey], + // // ["a", `${event.kind}:${event.id}:${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=${1000}&nostr=${encodeURI( + const zapRequestAPICall = `${callbackUrl}?amount=${amount}&nostr=${encodeURI( JSON.stringify(signedEvent) )}`; @@ -149,10 +180,10 @@ export function useNostr() { const hasResource = eventData.some(([tag, value]) => tag === "t" && value === "resource"); return hasPlebDevs && hasResource; }; - + return new Promise((resolve, reject) => { let resources = []; - + const subscription = subscribe( filter, { @@ -172,14 +203,14 @@ export function useNostr() { }, 2000 // Adjust the timeout value as needed ); - + setTimeout(() => { subscription?.close(); resolve(resources); }, 2000); // Adjust the timeout value as needed }); }, [subscribe]); - + const fetchWorkshops = useCallback(async () => { const filter = [{ kinds: [30023], authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"] }]; const hasRequiredTags = (eventData) => { @@ -187,10 +218,10 @@ export function useNostr() { const hasWorkshop = eventData.some(([tag, value]) => tag === "t" && value === "workshop"); return hasPlebDevs && hasWorkshop; }; - + return new Promise((resolve, reject) => { let workshops = []; - + const subscription = subscribe( filter, { @@ -210,14 +241,14 @@ export function useNostr() { }, 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: [30023], authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"] }]; const hasRequiredTags = (eventData) => { @@ -225,10 +256,10 @@ export function useNostr() { const hasCourse = eventData.some(([tag, value]) => tag === "t" && value === "course"); return hasPlebDevs && hasCourse; }; - + return new Promise((resolve, reject) => { let courses = []; - + const subscription = subscribe( filter, { @@ -248,7 +279,7 @@ export function useNostr() { }, 2000 // Adjust the timeout value as needed ); - + setTimeout(() => { subscription?.close(); resolve(courses); diff --git a/src/utils/lnurl.js b/src/utils/lnurl.js new file mode 100644 index 0000000..39f1b88 --- /dev/null +++ b/src/utils/lnurl.js @@ -0,0 +1,12 @@ +import {bech32} from 'bech32'; + +export const lnurlEncode = (data) => { + console.log('data:', data); + const words = bech32.toWords(Buffer.from(data, 'utf8')); + return bech32.encode("lnurl", words, 2000).toUpperCase() +}; + +export const lnurlDecode = (encoded) => { + const { words } = bech32.decode(encoded, 90); + return Buffer.from(bech32.fromWords(words)).toString('utf8'); +}; \ No newline at end of file diff --git a/src/utils/nostr.js b/src/utils/nostr.js index c885425..ddb42d3 100644 --- a/src/utils/nostr.js +++ b/src/utils/nostr.js @@ -35,6 +35,7 @@ export const parseEvent = (event) => { id: event.id, pubkey: event.pubkey || '', content: event.content || '', + kind: event.kind || '', title: '', summary: '', image: '', @@ -59,6 +60,9 @@ export const parseEvent = (event) => { case 'author': eventData.author = tag[1]; break; + case 'd': + eventData.d = tag[1]; + break; default: break; }