Working on zaps

This commit is contained in:
austinkelsay 2024-04-22 19:09:46 -05:00
parent edf97c74fa
commit a255f1e5b0
10 changed files with 211 additions and 91 deletions

6
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@prisma/client": "^5.9.1", "@prisma/client": "^5.9.1",
"@reduxjs/toolkit": "^2.1.0", "@reduxjs/toolkit": "^2.1.0",
"axios": "^1.6.7", "axios": "^1.6.7",
"bech32": "^2.0.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"light-bolt11-decoder": "^3.1.1", "light-bolt11-decoder": "^3.1.1",
"next": "14.0.4", "next": "14.0.4",
@ -1378,6 +1379,11 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true "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": { "node_modules/binary-extensions": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",

View File

@ -13,6 +13,7 @@
"@prisma/client": "^5.9.1", "@prisma/client": "^5.9.1",
"@reduxjs/toolkit": "^2.1.0", "@reduxjs/toolkit": "^2.1.0",
"axios": "^1.6.7", "axios": "^1.6.7",
"bech32": "^2.0.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"light-bolt11-decoder": "^3.1.1", "light-bolt11-decoder": "^3.1.1",
"next": "14.0.4", "next": "14.0.4",

View File

@ -5,73 +5,71 @@ import { formatTimestampToHowLongAgo } from "@/utils/time";
import { useImageProxy } from "@/hooks/useImageProxy"; import { useImageProxy } from "@/hooks/useImageProxy";
import { useNostr } from "@/hooks/useNostr"; import { useNostr } from "@/hooks/useNostr";
import { getSatAmountFromInvoice } from "@/utils/lightning"; import { getSatAmountFromInvoice } from "@/utils/lightning";
import ZapDisplay from "@/components/zaps/ZapDisplay";
const CourseTemplate = (course) => { const CourseTemplate = (course) => {
const [zaps, setZaps] = useState([]); const [zaps, setZaps] = useState([]);
const [zapAmount, setZapAmount] = useState(null); const [zapAmount, setZapAmount] = useState(null);
const router = useRouter(); const router = useRouter();
const { returnImageProxy } = useImageProxy(); const { returnImageProxy } = useImageProxy();
const { fetchZapsForEvent } = useNostr(); const { fetchZapsForEvent } = useNostr();
useEffect(() => { useEffect(() => {
const fetchZaps = async () => { const fetchZaps = async () => {
try { try {
const zaps = await fetchZapsForEvent(course.id); const zaps = await fetchZapsForEvent(course);
setZaps(zaps); // console.log('zaps:', zaps);
} catch (error) { if (zaps.length > 0) {
console.error("Error fetching zaps:", error); let total = 0;
} zaps.map((zap) => {
}; const bolt11Tag = zap.tags.find((tag) => tag[0] === "bolt11");
fetchZaps(); const invoice = bolt11Tag ? bolt11Tag[1] : null;
}, [fetchZapsForEvent, course]); if (invoice) {
const amount = getSatAmountFromInvoice(invoice);
total += amount;
}
});
setZapAmount(total);
}
} catch (error) {
console.error("Error fetching zaps:", error);
}
};
fetchZaps();
}, [fetchZapsForEvent, course]);
useEffect(() => { return (
if (zaps.length > 0) { <div
zaps.map((zap) => { className="flex flex-col items-center mx-auto px-4 mt-8 rounded-md"
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%"}}
> >
<Image <div
alt="course thumbnail" onClick={() => router.push(`/details/${course.id}`)}
src={returnImageProxy(course.image)} className="relative w-full h-0 hover:opacity-80 transition-opacity duration-300 cursor-pointer"
quality={100} style={{ paddingBottom: "56.25%" }}
layout="fill" >
objectFit="cover" <Image
className="rounded-md" alt="course thumbnail"
/> src={returnImageProxy(course.image)}
</div> quality={100}
<div className="flex flex-col justify-start w-full mt-4"> layout="fill"
<h4 className="mb-1 font-bold text-lg font-blinker line-clamp-2"> objectFit="cover"
{course.title} className="rounded-md"
</h4> />
<p className="text-sm text-gray-500 line-clamp-2">{course.summary}</p> </div>
<div className="flex flex-row justify-between items-center mt-2"> <div className="flex flex-col justify-start w-full mt-4">
<p className="text-xs text-gray-400"> <h4 className="mb-1 font-bold text-lg font-blinker line-clamp-2">
{formatTimestampToHowLongAgo(course.published_at)} {course.title}
</p> </h4>
<p className="text-xs cursor-pointer"> <p className="text-sm text-gray-500 line-clamp-2">{course.summary}</p>
<i className="pi pi-bolt text-yellow-300"></i> {zapAmount} <div className="flex flex-row justify-between items-center mt-2">
</p> <p className="text-xs text-gray-400">
{formatTimestampToHowLongAgo(course.published_at)}
</p>
<ZapDisplay zapAmount={zapAmount} event={course} />
</div>
</div>
</div> </div>
</div> );
</div>
);
}; };
export default CourseTemplate; export default CourseTemplate;

View File

@ -16,7 +16,7 @@ const ResourceTemplate = (resource) => {
useEffect(() => { useEffect(() => {
const fetchZaps = async () => { const fetchZaps = async () => {
try { try {
const zaps = await fetchZapsForEvent(resource.id); const zaps = await fetchZapsForEvent(resource);
setZaps(zaps); setZaps(zaps);
} catch (error) { } catch (error) {
console.error("Error fetching zaps:", 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 */} {/* 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 <div
onClick={() => router.push(`/details/${resource.id}`)} 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%"}} style={{ paddingBottom: "56.25%"}}
> >
<Image <Image

View File

@ -16,7 +16,7 @@ const WorkshopTemplate = (workshop) => {
useEffect(() => { useEffect(() => {
const fetchZaps = async () => { const fetchZaps = async () => {
try { try {
const zaps = await fetchZapsForEvent(workshop.id); const zaps = await fetchZapsForEvent(workshop);
setZaps(zaps); setZaps(zaps);
} catch (error) { } catch (error) {
console.error("Error fetching zaps:", error); console.error("Error fetching zaps:", error);
@ -44,7 +44,7 @@ const WorkshopTemplate = (workshop) => {
> >
<div <div
onClick={() => router.push(`/details/${workshop.id}`)} 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%"}} style={{ paddingBottom: "56.25%"}}
> >
<Image <Image

View File

@ -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;

View File

@ -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;

View File

@ -1,7 +1,8 @@
import { useState, useEffect, useCallback, useContext } from 'react'; import { useState, useEffect, useCallback, useContext } from 'react';
import axios from 'axios'; import axios from 'axios';
import { nip57 } from 'nostr-tools'; import { nip57, nip19 } from 'nostr-tools';
import { NostrContext } from '@/context/NostrContext'; import { NostrContext } from '@/context/NostrContext';
import { lnurlEncode } from '@/utils/lnurl';
const defaultRelays = [ const defaultRelays = [
"wss://nos.lol/", "wss://nos.lol/",
@ -11,19 +12,19 @@ const defaultRelays = [
"wss://nostr.mutinywallet.com/", "wss://nostr.mutinywallet.com/",
"wss://relay.mutinywallet.com/", "wss://relay.mutinywallet.com/",
"wss://relay.primal.net/" "wss://relay.primal.net/"
]; ];
export function useNostr() { export function useNostr() {
const pool = useContext(NostrContext); const pool = useContext(NostrContext);
const subscribe = useCallback( const subscribe = useCallback(
(filters, opts) => { (filters, opts) => {
if (!pool) return; if (!pool) return;
return pool.subscribeMany(defaultRelays, filters, opts); return pool.subscribeMany(defaultRelays, filters, opts);
}, },
[pool] [pool]
); );
const publish = useCallback( const publish = useCallback(
async (event) => { async (event) => {
@ -55,11 +56,16 @@ export function useNostr() {
); );
const fetchZapsForEvent = useCallback( const fetchZapsForEvent = useCallback(
async (id) => { async (event) => {
try { try {
const filter = { kinds: [9735], '#e': [id] }; let zaps = [];
const zaps = await pool.querySync(defaultRelays, filter); const paramaterizedFilter = { kinds: [9735], '#a': [`${event.kind}:${event.id}:${event.d}`] };
console.log('zaps:', zaps); 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; return zaps;
} catch (error) { } catch (error) {
console.error('Failed to fetch zaps for event:', error); console.error('Failed to fetch zaps for event:', error);
@ -84,7 +90,7 @@ export function useNostr() {
); );
const zapEvent = useCallback( const zapEvent = useCallback(
async (event) => { async (event, amount, comment) => {
const kind0 = await fetchKind0(event.pubkey); const kind0 = await fetchKind0(event.pubkey);
if (kind0.length === 0) { if (kind0.length === 0) {
@ -104,16 +110,41 @@ export function useNostr() {
const zapReq = nip57.makeZapRequest({ const zapReq = nip57.makeZapRequest({
profile: event.pubkey, profile: event.pubkey,
event: event.id, event: event.id,
amount: 1000, amount: amount,
relays: defaultRelays, 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); const signedEvent = await window?.nostr?.signEvent(zapReq);
console.log('signedEvent:', signedEvent);
const callbackUrl = response.data.callback; const callbackUrl = response.data.callback;
const zapRequestAPICall = `${callbackUrl}?amount=${1000}&nostr=${encodeURI( const zapRequestAPICall = `${callbackUrl}?amount=${amount}&nostr=${encodeURI(
JSON.stringify(signedEvent) JSON.stringify(signedEvent)
)}`; )}`;

12
src/utils/lnurl.js Normal file
View File

@ -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');
};

View File

@ -35,6 +35,7 @@ export const parseEvent = (event) => {
id: event.id, id: event.id,
pubkey: event.pubkey || '', pubkey: event.pubkey || '',
content: event.content || '', content: event.content || '',
kind: event.kind || '',
title: '', title: '',
summary: '', summary: '',
image: '', image: '',
@ -59,6 +60,9 @@ export const parseEvent = (event) => {
case 'author': case 'author':
eventData.author = tag[1]; eventData.author = tag[1];
break; break;
case 'd':
eventData.d = tag[1];
break;
default: default:
break; break;
} }