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