forked from Kieran/snort
LNURL tipping
This commit is contained in:
parent
8c0b0ac986
commit
037f39e386
11
src/Util.js
11
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
|
||||
|
15
src/element/LNURLTip.css
Normal file
15
src/element/LNURLTip.css
Normal 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
114
src/element/LNURLTip.js
Normal 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>
|
||||
)
|
||||
}
|
@ -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} />
|
||||
{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>
|
||||
)
|
||||
}
|
@ -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
41
src/element/QrCode.js
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
|
@ -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"> ⚡️ {lud16}</div>
|
||||
<div className="f-ellipsis"> ⚡️ {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)}/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user