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

View File

@ -10,6 +10,7 @@ import { FileExtensionRegex } from "../Const";
export function NoteCreator(props) {
const replyTo = props.replyTo;
const onSend = props.onSend;
const show = props.show || false;
const publisher = useEventPublisher();
const [note, setNote] = useState("");
const [error, setError] = useState("");
@ -29,19 +30,20 @@ export function NoteCreator(props) {
async function attachFile() {
try {
let file = await openFile();
let rsp = await VoidUpload(file);
let ext = file.name.match(FileExtensionRegex)[1];
let file = await openFile();
let rsp = await VoidUpload(file);
let ext = file.name.match(FileExtensionRegex)[1];
// extension tricks note parser to embed the content
let url = rsp.metadata.url ?? `https://void.cat/d/${rsp.id}.${ext}`;
// extension tricks note parser to embed the content
let url = rsp.metadata.url ?? `https://void.cat/d/${rsp.id}.${ext}`;
setNote(n => `${n}\n${url}`);
setNote(n => `${n}\n${url}`);
} catch (error) {
setError(error?.message)
setError(error?.message)
}
}
if (!show) return false;
return (
<>
{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="attachment flex f-row">
{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 className="btn" onClick={() => sendNote()}>Send</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;
}
span.pill.active {
background-color: #444;
}
span.pill:hover {
cursor: pointer;
}
@ -177,6 +181,10 @@ body.scroll-lock {
margin-left: 5px;
}
.mb10 {
margin-bottom: 10px;
}
.tabs {
display: flex;
align-content: center;

View File

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

View File

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