LNURL tipping

This commit is contained in:
Kieran 2023-01-07 20:54:12 +00:00
parent 8c0b0ac986
commit 037f39e386
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
9 changed files with 236 additions and 32 deletions

View File

@ -33,6 +33,17 @@ export function bech32ToHex(str) {
return secp.utils.bytesToHex(Uint8Array.from(buff)); return secp.utils.bytesToHex(Uint8Array.from(buff));
} }
/**
* Decode bech32 to string UTF-8
* @param {string} str bech32 encoded string
* @returns
*/
export function bech32ToText(str) {
let decoded = bech32.decode(str, 1000);
let buf = bech32.fromWords(decoded.words);
return new TextDecoder().decode(Uint8Array.from(buf));
}
/** /**
* Convert hex note id to bech32 link url * Convert hex note id to bech32 link url
* @param {string} hex * @param {string} hex

15
src/element/LNURLTip.css Normal file
View File

@ -0,0 +1,15 @@
.lnurl-tip {
background-color: #222;
padding: 10px;
border-radius: 10px;
width: 50vw;
text-align: center;
min-height: 10vh;
}
@media(max-width: 720px) {
.lnurl-tip {
width: 100vw;
margin: 0 10px;
}
}

114
src/element/LNURLTip.js Normal file
View File

@ -0,0 +1,114 @@
import "./LNURLTip.css";
import { useEffect, useMemo, useState } from "react";
import { bech32ToText } from "../Util";
import Modal from "./Modal";
import QrCode from "./QrCode";
export default function LNURLTip(props) {
const onClose = props.onClose || (() => { });
const service = props.svc;
const show = props.show || false;
const amounts = [50, 100, 500, 1_000, 5_000, 10_000];
const [payService, setPayService] = useState("");
const [amount, setAmount] = useState(0);
const [invoice, setInvoice] = useState("");
const [comment, setComment] = useState("");
const [error, setError] = useState("")
async function fetchJson(url) {
let rsp = await fetch(url);
if (rsp.ok) {
let data = await rsp.json();
console.log(data);
return data;
}
return null;
}
async function loadService() {
let isServiceUrl = service.toLowerCase().startsWith("lnurl");
if (isServiceUrl) {
let serviceUrl = bech32ToText(service);
return await fetchJson(serviceUrl);
} else {
let ns = service.split("@");
return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`);
}
}
async function loadInvoice() {
if (amount === 0) return null;
const url = `${payService.callback}?amount=${parseInt(amount * 1000)}&comment=${encodeURIComponent(comment)}`;
try {
let rsp = await fetch(url);
if (rsp.ok) {
let data = await rsp.json();
console.log(data);
if (data.status === "ERROR") {
setError(data.reason);
} else {
setInvoice(data.pr);
}
} else {
setError("Failed to load invoice");
}
} catch (e) {
setError("Failed to load invoice");
}
};
useEffect(() => {
if (payService && amount > 0) {
loadInvoice();
}
}, [payService, amount]);
useEffect(() => {
if (show) {
loadService()
.then(a => setPayService(a))
.catch(() => setError("Failed to load LNURL service"));
}
}, [show, service]);
const serviceAmounts = useMemo(() => {
if (payService) {
let min = (payService.minSendable ?? 0) / 1000;
let max = (payService.maxSendable ?? 0) / 1000;
return amounts.filter(a => a >= min && a <= max);
}
return [];
}, [payService]);
const metadata = useMemo(() => {
if (payService) {
let meta = JSON.parse(payService.metadata);
return {
description: meta.find(a => a[0] === "text/plain")[1]
};
}
return null;
}, [payService]);
if (!show) return null;
return (
<Modal onClose={() => onClose()}>
<div className="lnurl-tip" onClick={(e) => e.stopPropagation()}>
<h2> Send sats</h2>
<div className="f-ellipsis mb10">{service}</div>
<div className="f-ellipsis mb10">{metadata?.description}</div>
<div className="flex">
{payService?.commentAllowed > 0 ?
<input type="text" placeholder="Comment" className="mb10 f-grow" maxLength={payService?.commentAllowed} onChange={(e) => setComment(e.target.value)} /> : null}
</div>
<div className="mb10">
{serviceAmounts.map(a => <span className={`pill ${amount === a ? "active" : ""}`} key={a} onClick={() => setAmount(a)}>
{a.toLocaleString()}
</span>)}
</div>
{error ? <p className="error">{error}</p> : null}
<QrCode link={invoice} />
</div>
</Modal>
)
}

View File

@ -2,8 +2,8 @@ import "./Note.css";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import moment from "moment"; import moment from "moment";
import { Link, useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { faHeart, faThumbsDown, faReply, faInfo, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faHeart, faReply, faThumbsDown, faTrash, faBolt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Event from "../nostr/Event"; import Event from "../nostr/Event";
@ -12,6 +12,7 @@ import useEventPublisher from "../feed/EventPublisher";
import { NoteCreator } from "./NoteCreator"; import { NoteCreator } from "./NoteCreator";
import { extractLinks, extractMentions, extractInvoices } from "../Text"; import { extractLinks, extractMentions, extractInvoices } from "../Text";
import { eventLink } from "../Util"; import { eventLink } from "../Util";
import LNURLTip from "./LNURLTip";
export default function Note(props) { export default function Note(props) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -28,13 +29,15 @@ export default function Note(props) {
const likes = reactions?.filter(({ Content }) => Content === "+" || Content === "❤️").length ?? 0 const likes = reactions?.filter(({ Content }) => Content === "+" || Content === "❤️").length ?? 0
const dislikes = reactions?.filter(({ Content }) => Content === "-").length ?? 0 const dislikes = reactions?.filter(({ Content }) => Content === "-").length ?? 0
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const [showReply, setShowReply] = useState(false); const [reply, setReply] = useState(false);
const [tip, setTip] = useState(false);
const users = useSelector(s => s.users?.users); const users = useSelector(s => s.users?.users);
const login = useSelector(s => s.login.publicKey); const login = useSelector(s => s.login.publicKey);
const ev = dataEvent ?? Event.FromObject(data); const ev = dataEvent ?? Event.FromObject(data);
const isMine = ev.PubKey === login; const isMine = ev.PubKey === login;
const liked = reactions?.find(({ PubKey, Content }) => Content === "+" && PubKey === login) const liked = reactions?.find(({ PubKey, Content }) => Content === "+" && PubKey === login)
const disliked = reactions?.find(({ PubKey, Content }) => Content === "-" && PubKey === login) const disliked = reactions?.find(({ PubKey, Content }) => Content === "-" && PubKey === login)
const author = users[ev.PubKey];
const options = { const options = {
showHeader: true, showHeader: true,
@ -106,6 +109,20 @@ export default function Note(props) {
} }
} }
function tipButton() {
let service = author?.lud16 || author?.lud06;
if (service) {
return (
<>
<span className="pill" onClick={(e) => setTip(true)}>
<FontAwesomeIcon icon={faBolt} />
</span>
</>
)
}
return null;
}
if (!ev.IsContent()) { if (!ev.IsContent()) {
return ( return (
<> <>
@ -132,9 +149,10 @@ export default function Note(props) {
{options.showFooter ? {options.showFooter ?
<div className="footer"> <div className="footer">
{isMine ? <span className="pill"> {isMine ? <span className="pill">
<FontAwesomeIcon icon={faTrash} onClick={() => deleteEvent()} /> <FontAwesomeIcon icon={faTrash} onClick={(e) => deleteEvent()} />
</span> : null} </span> : null}
<span className="pill" onClick={() => setShowReply(!showReply)}> {tipButton()}
<span className="pill" onClick={(e) => setReply(s => !s)}>
<FontAwesomeIcon icon={faReply} /> <FontAwesomeIcon icon={faReply} />
</span> </span>
{Object.keys(emojiReactions).map((emoji) => { {Object.keys(emojiReactions).map((emoji) => {
@ -156,11 +174,9 @@ export default function Note(props) {
<FontAwesomeIcon color={disliked ? "orange" : "currentColor"} icon={faThumbsDown} /> &nbsp; <FontAwesomeIcon color={disliked ? "orange" : "currentColor"} icon={faThumbsDown} /> &nbsp;
{dislikes} {dislikes}
</span> </span>
<span className="pill" onClick={() => console.debug(ev)}>
<FontAwesomeIcon icon={faInfo} />
</span>
</div> : null} </div> : null}
{showReply ? <NoteCreator replyTo={ev} onSend={() => setShowReply(false)} /> : null} <NoteCreator replyTo={ev} onSend={(e) => setReply(false)} show={reply} />
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={(e) => setTip(false)} show={tip} />
</div> </div>
) )
} }

View File

@ -10,6 +10,7 @@ import { FileExtensionRegex } from "../Const";
export function NoteCreator(props) { export function NoteCreator(props) {
const replyTo = props.replyTo; const replyTo = props.replyTo;
const onSend = props.onSend; const onSend = props.onSend;
const show = props.show || false;
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -29,19 +30,20 @@ export function NoteCreator(props) {
async function attachFile() { async function attachFile() {
try { try {
let file = await openFile(); let file = await openFile();
let rsp = await VoidUpload(file); let rsp = await VoidUpload(file);
let ext = file.name.match(FileExtensionRegex)[1]; let ext = file.name.match(FileExtensionRegex)[1];
// extension tricks note parser to embed the content // extension tricks note parser to embed the content
let url = rsp.metadata.url ?? `https://void.cat/d/${rsp.id}.${ext}`; let url = rsp.metadata.url ?? `https://void.cat/d/${rsp.id}.${ext}`;
setNote(n => `${n}\n${url}`); setNote(n => `${n}\n${url}`);
} catch (error) { } catch (error) {
setError(error?.message) setError(error?.message)
} }
} }
if (!show) return false;
return ( return (
<> <>
{replyTo ? <small>{`Reply to: ${replyTo.Id.substring(0, 8)}`}</small> : null} {replyTo ? <small>{`Reply to: ${replyTo.Id.substring(0, 8)}`}</small> : null}
@ -51,7 +53,7 @@ export function NoteCreator(props) {
<div className="actions flex f-row"> <div className="actions flex f-row">
<div className="attachment flex f-row"> <div className="attachment flex f-row">
{error.length > 0 ? <b className="error">{error}</b> : null} {error.length > 0 ? <b className="error">{error}</b> : null}
<FontAwesomeIcon icon={faPaperclip} size="xl" onClick={(e) => attachFile()}/> <FontAwesomeIcon icon={faPaperclip} size="xl" onClick={(e) => attachFile()} />
</div> </div>
<div className="btn" onClick={() => sendNote()}>Send</div> <div className="btn" onClick={() => sendNote()}>Send</div>
</div> </div>

41
src/element/QrCode.js Normal file
View File

@ -0,0 +1,41 @@
import QRCodeStyling from "qr-code-styling";
import {useEffect, useRef} from "react";
export default function QrCode(props) {
const qrRef = useRef();
const link = props.link;
useEffect(() => {
console.log("Showing QR: ", link);
if (link?.length > 0) {
let qr = new QRCodeStyling({
width: props.width || 256,
height: props.height || 256,
data: link,
margin: 5,
type: 'canvas',
image: props.avatar,
dotsOptions: {
type: 'rounded'
},
cornersSquareOptions: {
type: 'extra-rounded'
},
imageOptions: {
crossOrigin: "anonymous"
}
});
qrRef.current.innerHTML = "";
qr.append(qrRef.current);
qrRef.current.onclick = function (e) {
let elm = document.createElement("a");
elm.href = `lightning:${link}`;
elm.click();
}
}
}, [link]);
return (
<div className="qr" ref={qrRef}></div>
);
}

View File

@ -118,6 +118,10 @@ span.pill {
margin: 2px 5px; margin: 2px 5px;
} }
span.pill.active {
background-color: #444;
}
span.pill:hover { span.pill:hover {
cursor: pointer; cursor: pointer;
} }
@ -177,6 +181,10 @@ body.scroll-lock {
margin-left: 5px; margin-left: 5px;
} }
.mb10 {
margin-bottom: 10px;
}
.tabs { .tabs {
display: flex; display: flex;
align-content: center; align-content: center;

View File

@ -1,7 +1,6 @@
import "./ProfilePage.css"; import "./ProfilePage.css";
import { useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { bech32 } from "bech32";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faQrcode, faCopy, faCheck } from "@fortawesome/free-solid-svg-icons"; import { faQrcode, faCopy, faCheck } from "@fortawesome/free-solid-svg-icons";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
@ -11,14 +10,14 @@ import { resetProfile } from "../state/Users";
import Nostrich from "../nostrich.jpg"; import Nostrich from "../nostrich.jpg";
import useEventPublisher from "../feed/EventPublisher"; import useEventPublisher from "../feed/EventPublisher";
import QRCodeStyling from "qr-code-styling"; import QRCodeStyling from "qr-code-styling";
import Modal from "../element/Modal";
import { logout } from "../state/Login"; import { logout } from "../state/Login";
import FollowButton from "../element/FollowButton"; import FollowButton from "../element/FollowButton";
import VoidUpload from "../feed/VoidUpload"; import VoidUpload from "../feed/VoidUpload";
import { openFile, parseId } from "../Util"; import { bech32ToText, openFile, parseId } from "../Util";
import Timeline from "../element/Timeline"; import Timeline from "../element/Timeline";
import { extractLinks } from '../Text' import { extractLinks } from '../Text'
import { useCopy } from '../useCopy' import { useCopy } from '../useCopy'
import LNURLTip from "../element/LNURLTip";
export default function ProfilePage() { export default function ProfilePage() {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -36,16 +35,18 @@ export default function ProfilePage() {
const [about, setAbout] = useState(""); const [about, setAbout] = useState("");
const [website, setWebsite] = useState(""); const [website, setWebsite] = useState("");
const [nip05, setNip05] = useState(""); const [nip05, setNip05] = useState("");
const [lud06, setLud06] = useState("");
const [lud16, setLud16] = useState(""); const [lud16, setLud16] = useState("");
const [showLnQr, setShowLnQr] = useState(false); const [showLnQr, setShowLnQr] = useState(false);
useMemo(() => { useEffect(() => {
if (user) { if (user) {
setName(user.name ?? ""); setName(user.name ?? "");
setPicture(user.picture ?? ""); setPicture(user.picture ?? "");
setAbout(user.about ?? ""); setAbout(user.about ?? "");
setWebsite(user.website ?? ""); setWebsite(user.website ?? "");
setNip05(user.nip05 ?? ""); setNip05(user.nip05 ?? "");
setLud06(user.lud06 ?? "");
setLud16(user.lud16 ?? ""); setLud16(user.lud16 ?? "");
} }
}, [user]); }, [user]);
@ -53,8 +54,7 @@ export default function ProfilePage() {
useMemo(() => { useMemo(() => {
// some clients incorrectly set this to LNURL service, patch this // some clients incorrectly set this to LNURL service, patch this
if (lud16.toLowerCase().startsWith("lnurl")) { if (lud16.toLowerCase().startsWith("lnurl")) {
let decoded = bech32.decode(lud16, 1000); let url = bech32ToText(lud16);
let url = new TextDecoder().decode(Uint8Array.from(bech32.fromWords(decoded.words)));
if (url.startsWith("http")) { if (url.startsWith("http")) {
let parsedUri = new URL(url); let parsedUri = new URL(url);
// is lightning address // is lightning address
@ -172,6 +172,7 @@ export default function ProfilePage() {
} }
function details() { function details() {
const lnurl = lud16 || lud06;
return ( return (
<> <>
<div className="flex name"> <div className="flex name">
@ -195,17 +196,13 @@ export default function ProfilePage() {
<p>{extractLinks([about])}</p> <p>{extractLinks([about])}</p>
{website ? <a href={website} target="_blank" rel="noreferrer">{website}</a> : null} {website ? <a href={website} target="_blank" rel="noreferrer">{website}</a> : null}
{lud16 ? <div className="flex"> {lnurl ? <div className="flex">
<div className="btn" onClick={(e) => setShowLnQr(true)}> <div className="btn" onClick={(e) => setShowLnQr(true)}>
<FontAwesomeIcon icon={faQrcode} size="xl" /> <FontAwesomeIcon icon={faQrcode} size="xl" />
</div> </div>
<div className="f-ellipsis">&nbsp; {lud16}</div> <div className="f-ellipsis">&nbsp; {lnurl}</div>
</div> : null} </div> : null}
{showLnQr === true ? <LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)}/>
<Modal onClose={() => setShowLnQr(false)}>
<h4>{lud16}</h4>
<div ref={qrRef}></div>
</Modal> : null}
</> </>
) )
} }

View File

@ -25,7 +25,7 @@ export default function RootPage() {
return ( return (
<> <>
{pubKey ? <> {pubKey ? <>
<NoteCreator /> <NoteCreator show={true}/>
<div className="tabs root-tabs"> <div className="tabs root-tabs">
<div className={`f-1 ${tab === RootTab.Follows ? "active" : ""}`} onClick={() => setTab(RootTab.Follows)}> <div className={`f-1 ${tab === RootTab.Follows ? "active" : ""}`} onClick={() => setTab(RootTab.Follows)}>
Follows Follows