diff --git a/packages/app/src/Element/Modal.css b/packages/app/src/Element/Modal.css index cbbeeca9..4ce97359 100644 --- a/packages/app/src/Element/Modal.css +++ b/packages/app/src/Element/Modal.css @@ -9,6 +9,7 @@ justify-content: center; align-items: center; z-index: 42; + overflow-y: auto; } .modal-body { diff --git a/packages/app/src/Element/NoteCreator.tsx b/packages/app/src/Element/NoteCreator.tsx index cada2502..34b65919 100644 --- a/packages/app/src/Element/NoteCreator.tsx +++ b/packages/app/src/Element/NoteCreator.tsx @@ -1,6 +1,6 @@ import "./NoteCreator.css"; import { useState } from "react"; -import { FormattedMessage } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; import { RawEvent, TaggedRawEvent } from "@snort/nostr"; import Icon from "Icons/Icon"; @@ -11,6 +11,7 @@ import Modal from "Element/Modal"; import ProfileImage from "Element/ProfileImage"; import useFileUpload from "Upload"; import Note from "Element/Note"; +import { LNURL } from "LNURL"; import messages from "./messages"; @@ -40,16 +41,34 @@ export interface NoteCreatorProps { export function NoteCreator(props: NoteCreatorProps) { const { show, setShow, replyTo, onSend, autoFocus } = props; + const { formatMessage } = useIntl(); const publisher = useEventPublisher(); const [note, setNote] = useState(""); const [error, setError] = useState(""); const [active, setActive] = useState(false); const [preview, setPreview] = useState(); + const [showAdvanced, setShowAdvanced] = useState(false); + const [zapForward, setZapForward] = useState(""); const uploader = useFileUpload(); async function sendNote() { if (note) { - const ev = replyTo ? await publisher.reply(replyTo, note) : await publisher.note(note); + let extraTags: Array> | undefined; + if (zapForward) { + try { + const svc = new LNURL(zapForward); + await svc.load(); + extraTags = [svc.getZapTag()]; + } catch { + setError( + formatMessage({ + defaultMessage: "Invalid LNURL", + }) + ); + return; + } + } + const ev = replyTo ? await publisher.reply(replyTo, note, extraTags) : await publisher.note(note, extraTags); console.debug("Sending note: ", ev); publisher.broadcast(ev); setNote(""); @@ -92,6 +111,9 @@ export function NoteCreator(props: NoteCreatorProps) { function cancel() { setShow(false); setNote(""); + setShowAdvanced(false); + setPreview(undefined); + setZapForward(""); } function onSubmit(ev: React.MouseEvent) { @@ -149,8 +171,8 @@ export function NoteCreator(props: NoteCreatorProps) { )}
-
+ {showAdvanced && ( +
+ +

+ +

+

+ +

+ setZapForward(e.target.value)} + /> +
+ )} )} diff --git a/packages/app/src/Element/NoteFooter.tsx b/packages/app/src/Element/NoteFooter.tsx index dafba60c..cf7a35c8 100644 --- a/packages/app/src/Element/NoteFooter.tsx +++ b/packages/app/src/Element/NoteFooter.tsx @@ -153,10 +153,23 @@ export default function NoteFooter(props: NoteFooterProps) { } } + function getLNURL() { + return ev.tags.find(a => a[0] === "zap")?.[1] || author?.lud16 || author?.lud06; + } + + function getTargetName() { + const zapTarget = ev.tags.find(a => a[0] === "zap")?.[1]; + if (zapTarget) { + return new LNURL(zapTarget).name; + } else { + return author?.display_name || author?.name; + } + } + async function fastZap(e?: React.MouseEvent) { if (zapping || e?.isPropagationStopped()) return; - const lnurl = author?.lud16 || author?.lud06; + const lnurl = getLNURL(); if (wallet?.isReady() && lnurl) { setZapping(true); try { @@ -203,7 +216,7 @@ export default function NoteFooter(props: NoteFooterProps) { useEffect(() => { if (prefs.autoZap && !ZapCache.has(ev.id) && !isMine && !zapping) { - const lnurl = author?.lud16 || author?.lud06; + const lnurl = getLNURL(); if (wallet?.isReady() && lnurl) { setZapping(true); queueMicrotask(async () => { @@ -222,7 +235,7 @@ export default function NoteFooter(props: NoteFooterProps) { }, [prefs.autoZap, author, zapping]); function tipButton() { - const service = author?.lud16 || author?.lud06; + const service = getLNURL(); if (service) { return ( <> @@ -418,11 +431,11 @@ export default function NoteFooter(props: NoteFooterProps) { zaps={zaps} /> setTip(false)} show={tip} author={author?.pubkey} - target={author?.display_name || author?.name} + target={getTargetName()} note={ev.id} /> diff --git a/packages/app/src/Feed/EventPublisher.ts b/packages/app/src/Feed/EventPublisher.ts index e4769b65..f83a9d1e 100644 --- a/packages/app/src/Feed/EventPublisher.ts +++ b/packages/app/src/Feed/EventPublisher.ts @@ -176,10 +176,15 @@ export default function useEventPublisher() { return await signEvent(ev); } }, - note: async (msg: string) => { + note: async (msg: string, extraTags?: Array>) => { if (pubKey) { const ev = EventExt.forPubKey(pubKey, EventKind.TextNote); processContent(ev, msg); + if (extraTags) { + for (const et of extraTags) { + ev.tags.push(et); + } + } return await signEvent(ev); } }, @@ -200,7 +205,7 @@ export default function useEventPublisher() { /** * Reply to a note */ - reply: async (replyTo: TaggedRawEvent, msg: string) => { + reply: async (replyTo: TaggedRawEvent, msg: string, extraTags?: Array>) => { if (pubKey) { const ev = EventExt.forPubKey(pubKey, EventKind.TextNote); @@ -230,6 +235,11 @@ export default function useEventPublisher() { } } processContent(ev, msg); + if (extraTags) { + for (const et of extraTags) { + ev.tags.push(et); + } + } return await signEvent(ev); } }, diff --git a/packages/app/src/LNURL.ts b/packages/app/src/LNURL.ts index 94b3485b..2f80f692 100644 --- a/packages/app/src/LNURL.ts +++ b/packages/app/src/LNURL.ts @@ -48,6 +48,62 @@ export class LNURL { } } + /** + * URL of this payService + */ + get url() { + return this.#url; + } + + /** + * Return the optimal formatted LNURL + */ + get lnurl() { + if (this.isLNAddress) { + return this.getLNAddress(); + } + return this.#url.toString(); + } + + /** + * Human readable name for this service + */ + get name() { + // LN Address formatted URL + if (this.isLNAddress) { + return this.getLNAddress(); + } + // Generic LUD-06 url + return this.#url.hostname; + } + + /** + * Is this LNURL a LUD-16 Lightning Address + */ + get isLNAddress() { + return this.#url.pathname.startsWith("/.well-known/lnurlp/"); + } + + /** + * Get the LN Address for this LNURL + */ + getLNAddress() { + const pathParts = this.#url.pathname.split("/"); + const username = pathParts[pathParts.length - 1]; + return `${username}@${this.#url.hostname}`; + } + + /** + * Create a NIP-57 zap tag from this LNURL + */ + getZapTag() { + if (this.isLNAddress) { + return ["zap", this.getLNAddress(), "lud16"]; + } else { + return ["zap", this.#url.toString(), "lud06"]; + } + } + async load() { const rsp = await fetch(this.#url); if (rsp.ok) { diff --git a/packages/app/src/Pages/ProfilePage.tsx b/packages/app/src/Pages/ProfilePage.tsx index 4c9500bf..970c8904 100644 --- a/packages/app/src/Pages/ProfilePage.tsx +++ b/packages/app/src/Pages/ProfilePage.tsx @@ -24,7 +24,7 @@ import useModeration from "Hooks/useModeration"; import useZapsFeed from "Feed/ZapsFeed"; import { default as ZapElement } from "Element/Zap"; import FollowButton from "Element/FollowButton"; -import { extractLnAddress, parseId, hexToBech32 } from "Util"; +import { parseId, hexToBech32 } from "Util"; import Avatar from "Element/Avatar"; import Timeline from "Element/Timeline"; import Text from "Element/Text"; @@ -43,9 +43,11 @@ import Modal from "Element/Modal"; import BadgeList from "Element/BadgeList"; import { ProxyImg } from "Element/ProxyImg"; import useHorizontalScroll from "Hooks/useHorizontalScroll"; -import messages from "./messages"; import { EmailRegex } from "Const"; -import { getNip05PubKey } from "./Login"; +import { getNip05PubKey } from "Pages/Login"; +import { LNURL } from "LNURL"; + +import messages from "./messages"; const NOTES = 0; const REACTIONS = 1; @@ -111,7 +113,13 @@ export default function ProfilePage() { }); const npub = !id?.startsWith(NostrPrefix.PublicKey) ? hexToBech32(NostrPrefix.PublicKey, id || undefined) : id; - const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || ""); + const lnurl = (() => { + try { + return new LNURL(user?.lud16 || user?.lud06 || ""); + } catch { + // ignored + } + })(); const website_url = user?.website && !user.website.startsWith("http") ? "https://" + user.website : user?.website || ""; // feeds @@ -185,12 +193,12 @@ export default function ProfilePage() { {lnurl && (
setShowLnQr(true)}> - {lnurl} + {lnurl.name}
)} setShowLnQr(false)} author={id} diff --git a/packages/app/src/Util.ts b/packages/app/src/Util.ts index 1172cfc3..dbb277fe 100644 --- a/packages/app/src/Util.ts +++ b/packages/app/src/Util.ts @@ -146,26 +146,6 @@ export function getAllReactions(notes: readonly TaggedRawEvent[] | undefined, id return notes?.filter(a => a.kind === (kind ?? a.kind) && a.tags.some(a => a[0] === "e" && ids.includes(a[1]))) || []; } -/** - * Converts LNURL service to LN Address - */ -export function extractLnAddress(lnurl: string) { - // some clients incorrectly set this to LNURL service, patch this - if (lnurl.toLowerCase().startsWith("lnurl")) { - const url = bech32ToText(lnurl); - if (url.startsWith("http")) { - const parsedUri = new URL(url); - // is lightning address - if (parsedUri.pathname.startsWith("/.well-known/lnurlp/")) { - const pathParts = parsedUri.pathname.split("/"); - const username = pathParts[pathParts.length - 1]; - return `${username}@${parsedUri.hostname}`; - } - } - } - return lnurl; -} - export function unixNow() { return Math.floor(unixNowMs() / 1000); } diff --git a/packages/app/src/lang.json b/packages/app/src/lang.json index a36641fe..806dd221 100644 --- a/packages/app/src/lang.json +++ b/packages/app/src/lang.json @@ -78,6 +78,9 @@ "2k0Cv+": { "defaultMessage": "Dislikes ({n})" }, + "3Rx6Qo": { + "defaultMessage": "Advanced" + }, "3cc4Ct": { "defaultMessage": "Light" }, @@ -278,6 +281,9 @@ "FDguSC": { "defaultMessage": "{n} Zaps" }, + "FP+D3H": { + "defaultMessage": "LNURL to forward zaps to" + }, "FS3b54": { "defaultMessage": "Done!" }, @@ -437,6 +443,9 @@ "OLEm6z": { "defaultMessage": "Unknown login error" }, + "P04gQm": { + "defaultMessage": "All zaps sent to this note will be received by the following LNURL" + }, "P61BTu": { "defaultMessage": "Copy Event JSON" }, @@ -472,6 +481,9 @@ "defaultMessage": "Art by {name}", "description": "Artwork attribution label" }, + "R1fEdZ": { + "defaultMessage": "Forward Zaps" + }, "R2OqnW": { "defaultMessage": "Delete Account" }, diff --git a/packages/app/src/translations/en.json b/packages/app/src/translations/en.json index 592d6406..01e73d67 100644 --- a/packages/app/src/translations/en.json +++ b/packages/app/src/translations/en.json @@ -25,6 +25,7 @@ "2LbrkB": "Enter password", "2a2YiP": "{n} Bookmarks", "2k0Cv+": "Dislikes ({n})", + "3Rx6Qo": "Advanced", "3cc4Ct": "Light", "3gOsZq": "Translators", "3t3kok": "{n,plural,=1{{n} new note} other{{n} new notes}}", @@ -90,6 +91,7 @@ "Eqjl5K": "Only Snort and our integration partner identifier gives you a colorful domain name, but you are welcome to use other services too.", "F+B3x1": "We have also partnered with nostrplebs.com to give you more options", "FDguSC": "{n} Zaps", + "FP+D3H": "LNURL to forward zaps to", "FS3b54": "Done!", "FfYsOb": "An error has occured!", "FmXUJg": "follows you", @@ -142,6 +144,7 @@ "OEW7yJ": "Zaps", "OKhRC6": "Share", "OLEm6z": "Unknown login error", + "P04gQm": "All zaps sent to this note will be received by the following LNURL", "P61BTu": "Copy Event JSON", "P7FD0F": "System (Default)", "P7nJT9": "Total today (UTC): {amount} sats", @@ -153,6 +156,7 @@ "QTdJfH": "Create an Account", "QawghE": "You can change your username at any point.", "QxCuTo": "Art by {name}", + "R1fEdZ": "Forward Zaps", "R2OqnW": "Delete Account", "RDZVQL": "Check", "RahCRH": "Expired", diff --git a/packages/nostr/src/legacy/Tag.ts b/packages/nostr/src/legacy/Tag.ts index d2ca31b0..9b197832 100644 --- a/packages/nostr/src/legacy/Tag.ts +++ b/packages/nostr/src/legacy/Tag.ts @@ -12,6 +12,7 @@ export default class Tag { DTag?: string; Index: number; Invalid: boolean; + LNURL?: string; constructor(tag: string[], index: number) { this.Original = tag; @@ -50,6 +51,10 @@ export default class Tag { this.PubKey = tag[1]; break; } + case "zap": { + this.LNURL = tag[1]; + break; + } } }