From 037f39e3864b40c8ba9744dbf16d33c4a17c8bc4 Mon Sep 17 00:00:00 2001 From: Kieran Date: Sat, 7 Jan 2023 20:54:12 +0000 Subject: [PATCH] LNURL tipping --- src/Util.js | 11 ++++ src/element/LNURLTip.css | 15 +++++ src/element/LNURLTip.js | 114 +++++++++++++++++++++++++++++++++++++ src/element/Note.js | 34 ++++++++--- src/element/NoteCreator.js | 18 +++--- src/element/QrCode.js | 41 +++++++++++++ src/index.css | 8 +++ src/pages/ProfilePage.js | 25 ++++---- src/pages/Root.js | 2 +- 9 files changed, 236 insertions(+), 32 deletions(-) create mode 100644 src/element/LNURLTip.css create mode 100644 src/element/LNURLTip.js create mode 100644 src/element/QrCode.js diff --git a/src/Util.js b/src/Util.js index ff2e71a6..a05d85fa 100644 --- a/src/Util.js +++ b/src/Util.js @@ -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 diff --git a/src/element/LNURLTip.css b/src/element/LNURLTip.css new file mode 100644 index 00000000..a3a4e9b6 --- /dev/null +++ b/src/element/LNURLTip.css @@ -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; + } +} \ No newline at end of file diff --git a/src/element/LNURLTip.js b/src/element/LNURLTip.js new file mode 100644 index 00000000..f27e0af3 --- /dev/null +++ b/src/element/LNURLTip.js @@ -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 ( + onClose()}> +
e.stopPropagation()}> +

⚡️ Send sats

+
{service}
+
{metadata?.description}
+
+ {payService?.commentAllowed > 0 ? + setComment(e.target.value)} /> : null} +
+
+ {serviceAmounts.map(a => setAmount(a)}> + {a.toLocaleString()} + )} +
+ {error ?

{error}

: null} + +
+
+ ) +} \ No newline at end of file diff --git a/src/element/Note.js b/src/element/Note.js index b797823f..1429348e 100644 --- a/src/element/Note.js +++ b/src/element/Note.js @@ -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 ( + <> + setTip(true)}> + + + + ) + } + return null; + } + if (!ev.IsContent()) { return ( <> @@ -132,9 +149,10 @@ export default function Note(props) { {options.showFooter ?
{isMine ? - deleteEvent()} /> + deleteEvent()} /> : null} - setShowReply(!showReply)}> + {tipButton()} + setReply(s => !s)}> {Object.keys(emojiReactions).map((emoji) => { @@ -156,11 +174,9 @@ export default function Note(props) {   {dislikes} - console.debug(ev)}> - -
: null} - {showReply ? setShowReply(false)} /> : null} + setReply(false)} show={reply} /> + setTip(false)} show={tip} /> ) } \ No newline at end of file diff --git a/src/element/NoteCreator.js b/src/element/NoteCreator.js index f2b97d02..94e2a402 100644 --- a/src/element/NoteCreator.js +++ b/src/element/NoteCreator.js @@ -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 ? {`Reply to: ${replyTo.Id.substring(0, 8)}`} : null} @@ -51,7 +53,7 @@ export function NoteCreator(props) {
{error.length > 0 ? {error} : null} - attachFile()}/> + attachFile()} />
sendNote()}>Send
diff --git a/src/element/QrCode.js b/src/element/QrCode.js new file mode 100644 index 00000000..9a6d0ac2 --- /dev/null +++ b/src/element/QrCode.js @@ -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 ( +
+ ); +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index 459de56f..0647d0e7 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index 6d8ef4c5..ac45a4c3 100644 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -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 ( <>
@@ -195,17 +196,13 @@ export default function ProfilePage() {

{extractLinks([about])}

{website ? {website} : null} - {lud16 ?
+ {lnurl ?
setShowLnQr(true)}>
-
  ⚡️ {lud16}
+
  ⚡️ {lnurl}
: null} - {showLnQr === true ? - setShowLnQr(false)}> -

{lud16}

-
-
: null} + setShowLnQr(false)}/> ) } diff --git a/src/pages/Root.js b/src/pages/Root.js index 8361a800..785dadc6 100644 --- a/src/pages/Root.js +++ b/src/pages/Root.js @@ -25,7 +25,7 @@ export default function RootPage() { return ( <> {pubKey ? <> - +
setTab(RootTab.Follows)}> Follows