diff --git a/package.json b/package.json index 918f9009..0becb862 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/react-fontawesome": "^0.2.0", "@jukben/emoji-search": "^2.0.1", + "@noble/hashes": "^1.2.0", "@noble/secp256k1": "^1.7.0", "@protobufjs/base64": "^1.1.2", "@reduxjs/toolkit": "^1.9.1", diff --git a/src/Element/HyperText.tsx b/src/Element/HyperText.tsx index 52625ff4..0bb472ef 100644 --- a/src/Element/HyperText.tsx +++ b/src/Element/HyperText.tsx @@ -103,4 +103,4 @@ export default function HyperText({ link, creator }: { link: string, creator: He }, [link]); return render(); -} \ No newline at end of file +} diff --git a/src/Element/LNURLTip.tsx b/src/Element/LNURLTip.tsx index b25bf4a5..91930b48 100644 --- a/src/Element/LNURLTip.tsx +++ b/src/Element/LNURLTip.tsx @@ -1,12 +1,16 @@ import "./LNURLTip.css"; import { useEffect, useMemo, useState } from "react"; import { bech32ToText } from "Util"; +import { HexKey } from "Nostr"; +import useEventPublisher from "Feed/EventPublisher"; import Modal from "Element/Modal"; import QrCode from "Element/QrCode"; import Copy from "Element/Copy"; import useWebln from "Hooks/useWebln"; interface LNURLService { + allowsNostr?: boolean + nostrPubkey?: HexKey minSendable?: number, maxSendable?: number, metadata: string, @@ -31,12 +35,15 @@ export interface LNURLTipProps { invoice?: string, // shortcut to invoice qr tab title?: string, notice?: string + note?: HexKey + author?: HexKey } export default function LNURLTip(props: LNURLTipProps) { const onClose = props.onClose || (() => { }); const service = props.svc; const show = props.show || false; + const { note, author } = props const amounts = [50, 100, 500, 1_000, 5_000, 10_000, 50_000]; const [payService, setPayService] = useState(); const [amount, setAmount] = useState(); @@ -46,6 +53,7 @@ export default function LNURLTip(props: LNURLTipProps) { const [error, setError] = useState(); const [success, setSuccess] = useState(); const webln = useWebln(show); + const publisher = useEventPublisher(); useEffect(() => { if (show && !props.invoice) { @@ -117,7 +125,16 @@ export default function LNURLTip(props: LNURLTipProps) { async function loadInvoice() { if (!amount || !payService) return null; - const url = `${payService.callback}?amount=${Math.floor(amount * 1000)}${comment ? `&comment=${encodeURIComponent(comment)}` : ""}`; + let url = '' + const amountParam = `amount=${Math.floor(amount * 1000)}` + const commentParam = comment ? `&comment=${encodeURIComponent(comment)}` : "" + if (payService.allowsNostr && payService.nostrPubkey && author) { + const ev = await publisher.zap(author, note, comment) + const nostrParam = ev && `&nostr=${encodeURIComponent(JSON.stringify(ev.ToObject()))}` + url = `${payService.callback}?${amountParam}${commentParam}${nostrParam}`; + } else { + url = `${payService.callback}?${amountParam}${commentParam}`; + } try { let rsp = await fetch(url); if (rsp.ok) { @@ -235,4 +252,4 @@ export default function LNURLTip(props: LNURLTipProps) { ) -} \ No newline at end of file +} diff --git a/src/Element/NoteFooter.tsx b/src/Element/NoteFooter.tsx index 86c68a5e..2759d97a 100644 --- a/src/Element/NoteFooter.tsx +++ b/src/Element/NoteFooter.tsx @@ -14,6 +14,7 @@ import useEventPublisher from "Feed/EventPublisher"; import { getReactions, hexToBech32, normalizeReaction, Reaction } from "Util"; import { NoteCreator } from "Element/NoteCreator"; import LNURLTip from "Element/LNURLTip"; +import { parseZap, ZapsSummary } from "Element/Zap"; import { useUserProfile } from "Feed/ProfileFeed"; import { default as NEvent } from "Nostr/Event"; import { RootState } from "State/Store"; @@ -50,6 +51,9 @@ export default function NoteFooter(props: NoteFooterProps) { const langNames = new Intl.DisplayNames([...window.navigator.languages], { type: "language" }); const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related, ev]); const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related, ev]); + const zaps = useMemo(() => getReactions(related, ev.Id, EventKind.ZapReceipt).map(parseZap).filter(z => z.valid), [related]); + const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0) + const didZap = zaps.some(a => a.zapper === login); const groupReactions = useMemo(() => { return reactions?.reduce((acc, { content }) => { let r = normalizeReaction(content); @@ -97,10 +101,11 @@ export default function NoteFooter(props: NoteFooterProps) { if (service) { return ( <> -
setTip(true)}> +
setTip(true)}>
+ {zapTotal > 0 && (
{formatShort(zapTotal)}
)}
) @@ -259,7 +264,7 @@ export default function NoteFooter(props: NoteFooterProps) { show={reply} setShow={setReply} /> - setTip(false)} show={tip} /> + setTip(false)} show={tip} author={author?.pubkey} note={ev.Id} />
) } diff --git a/src/Element/Timeline.tsx b/src/Element/Timeline.tsx index b016fad9..2172fbab 100644 --- a/src/Element/Timeline.tsx +++ b/src/Element/Timeline.tsx @@ -7,6 +7,7 @@ import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed"; import { TaggedRawEvent } from "Nostr"; import EventKind from "Nostr/EventKind"; import LoadMore from "Element/LoadMore"; +import Zap, { parseZap } from "Element/Zap"; import Note from "Element/Note"; import NoteReaction from "Element/NoteReaction"; import useModeration from "Hooks/useModeration"; @@ -50,6 +51,9 @@ export default function Timeline({ subject, postsOnly = false, method, ignoreMod case EventKind.TextNote: { return } + case EventKind.ZapReceipt: { + return + } case EventKind.Reaction: case EventKind.Repost: { let eRef = e.tags.find(a => a[0] === "e")?.at(1); diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts index 2f3b5285..96663e46 100644 --- a/src/Feed/EventPublisher.ts +++ b/src/Feed/EventPublisher.ts @@ -144,6 +144,24 @@ export default function useEventPublisher() { return await signEvent(ev); } }, + zap: async (author: HexKey, note?: HexKey, msg?: string) => { + if (pubKey) { + let ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.ZapRequest; + if (note) { + // @ts-ignore + ev.Tags.push(new Tag(["e", note])) + } + // @ts-ignore + ev.Tags.push(new Tag(["p", author])) + // @ts-ignore + const relayTag = ['relays', ...Object.keys(relays)] + // @ts-ignore + ev.Tags.push(new Tag(relayTag)) + processContent(ev, msg || ''); + return await signEvent(ev); + } + }, /** * Reply to a note */ diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index 6d391485..4d212efc 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -41,7 +41,7 @@ export default function useLoginFeed() { let sub = new Subscriptions(); sub.Id = "login:notifications"; - sub.Kinds = new Set([EventKind.TextNote]); + sub.Kinds = new Set([EventKind.TextNote, EventKind.ZapReceipt]); sub.PTags = new Set([pubKey]); sub.Limit = 1; return sub; diff --git a/src/Feed/ThreadFeed.ts b/src/Feed/ThreadFeed.ts index 25d52d1a..b8054e2e 100644 --- a/src/Feed/ThreadFeed.ts +++ b/src/Feed/ThreadFeed.ts @@ -31,7 +31,7 @@ export default function useThreadFeed(id: u256) { // get replies to this event const subRelated = new Subscriptions(); - subRelated.Kinds = new Set(pref.enableReactions ? [EventKind.Reaction, EventKind.TextNote, EventKind.Deletion, EventKind.Repost] : [EventKind.TextNote]); + subRelated.Kinds = new Set(pref.enableReactions ? [EventKind.Reaction, EventKind.TextNote, EventKind.Deletion, EventKind.Repost, EventKind.ZapReceipt] : [EventKind.TextNote]); subRelated.ETags = thisSub.Ids; thisSub.AddSubscription(subRelated); @@ -56,4 +56,4 @@ export default function useThreadFeed(id: u256) { }, [main.store]); return main.store; -} \ No newline at end of file +} diff --git a/src/Feed/TimelineFeed.ts b/src/Feed/TimelineFeed.ts index c1287180..958cf278 100644 --- a/src/Feed/TimelineFeed.ts +++ b/src/Feed/TimelineFeed.ts @@ -107,7 +107,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel if (trackingEvents.length > 0 && pref.enableReactions) { sub = new Subscriptions(); sub.Id = `timeline-related:${subject.type}`; - sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion]); + sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion, EventKind.ZapReceipt]); sub.ETags = new Set(trackingEvents); } return sub ?? null; diff --git a/src/Feed/ZapsFeed.ts b/src/Feed/ZapsFeed.ts new file mode 100644 index 00000000..5f5c13af --- /dev/null +++ b/src/Feed/ZapsFeed.ts @@ -0,0 +1,17 @@ +import { useMemo } from "react"; +import { HexKey } from "Nostr"; +import EventKind from "Nostr/EventKind"; +import { Subscriptions } from "Nostr/Subscriptions"; +import useSubscription from "./Subscription"; + +export default function useZapsFeed(pubkey: HexKey) { + const sub = useMemo(() => { + let x = new Subscriptions(); + x.Id = `zaps:${pubkey}`; + x.Kinds = new Set([EventKind.ZapReceipt]); + x.PTags = new Set([pubkey]); + return x; + }, [pubkey]); + + return useSubscription(sub, { leaveOpen: true, cache: true }); +} diff --git a/src/Nostr/EventKind.ts b/src/Nostr/EventKind.ts index 9b69967a..86462c6b 100644 --- a/src/Nostr/EventKind.ts +++ b/src/Nostr/EventKind.ts @@ -10,6 +10,8 @@ const enum EventKind { Reaction = 7, // NIP-25 Auth = 22242, // NIP-42 Lists = 30000, // NIP-51 + ZapRequest = 9734, // NIP tba + ZapReceipt = 9735 // NIP tba }; export default EventKind; diff --git a/src/Pages/ProfilePage.css b/src/Pages/ProfilePage.css index 53c54e1e..be5a58d6 100644 --- a/src/Pages/ProfilePage.css +++ b/src/Pages/ProfilePage.css @@ -213,3 +213,8 @@ .qr-modal .modal-body { width: unset; } + +.profile .zap-amount { + font-weight: normal; + margin-left: 4px; +} diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index e3e65f47..a20e501e 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -4,11 +4,14 @@ import { useEffect, useMemo, useState } from "react"; import { useSelector } from "react-redux"; import { useNavigate, useParams } from "react-router-dom"; +import { formatShort } from "Number"; import Link from "Icons/Link"; import Qr from "Icons/Qr"; import Zap from "Icons/Zap"; import Envelope from "Icons/Envelope"; import { useUserProfile } from "Feed/ProfileFeed"; +import useZapsFeed from "Feed/ZapsFeed"; +import { parseZap } from "Element/Zap"; import FollowButton from "Element/FollowButton"; import { extractLnAddress, parseId, hexToBech32 } from "Util"; import Avatar from "Element/Avatar"; @@ -58,6 +61,11 @@ export default function ProfilePage() { const website_url = (user?.website && !user.website.startsWith("http")) ? "https://" + user.website : user?.website || ""; + const zapFeed = useZapsFeed(id) + const zaps = useMemo(() => { + return zapFeed.store.notes.map(parseZap).filter(z => z.valid && !z.e && z.p === id) + }, [zapFeed.store.notes, id]) + const zapsTotal = zaps.reduce((acc, z) => acc + z.amount, 0) useEffect(() => { setTab(ProfileTab.Notes); @@ -89,7 +97,7 @@ export default function ProfilePage() { )} - setShowLnQr(false)} /> + setShowLnQr(false)} author={id} /> ) } @@ -163,6 +171,9 @@ export default function ProfilePage() { <> setShowLnQr(true)}> + + {zapsTotal > 0 && formatShort(zapsTotal)} + {!loggedOut && ( <> diff --git a/src/Util.ts b/src/Util.ts index abd73dde..f5e9245a 100644 --- a/src/Util.ts +++ b/src/Util.ts @@ -1,8 +1,13 @@ import * as secp from "@noble/secp256k1"; +import { sha256 as hash } from '@noble/hashes/sha256'; import { bech32 } from "bech32"; -import { HexKey, TaggedRawEvent, u256 } from "Nostr"; +import { HexKey, RawEvent, TaggedRawEvent, u256 } from "Nostr"; import EventKind from "Nostr/EventKind"; +export const sha256 = (str: string) => { + return secp.utils.bytesToHex(hash(str)) +} + export async function openFile(): Promise { return new Promise((resolve, reject) => { let elm = document.createElement("input"); @@ -156,4 +161,4 @@ export function unixNow() { export function debounce(timeout: number, fn: () => void) { let t = setTimeout(fn, timeout); return () => clearTimeout(t); -} \ No newline at end of file +} diff --git a/src/element/Zap.css b/src/element/Zap.css new file mode 100644 index 00000000..b70e35f0 --- /dev/null +++ b/src/element/Zap.css @@ -0,0 +1,86 @@ +.zap { + background-color: var(--note-bg); + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding: 10px; + border-radius: 16px; + margin-bottom: 12px; +} + +.zap .summary { + display: flex; + align-items: center; + justify-content: space-between; +} + +.zap .body a { + color: var(--highlight); +} + +.amount { + font-size: 18px; +} + +.amount:before { + content: '⚡️ '; +} + +.top-zap .amount:before { + content: ''; +} + +.zaps-summary { + margin-top: 8px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; +} + +.top-zap { + font-size: 12px; + border: none; + margin: 0; +} + +.top-zap .pfp { + margin-right: .3em; +} + +.top-zap .summary { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; +} + +.top-zap .avatar { + width: 21px; + height: 21px; +} + +.top-zap .profile-name { + font-size: 12px; +} + +.top-zap .nip05 { + display: none; +} + +.top-zap .amount { + font-size: 12px; +} + +.amount-number { + font-weight: bold; +} + +.rest-zaps { + font-size: 12px; +} + +.rest-zaps:before { + content: ", "; +} diff --git a/src/element/Zap.tsx b/src/element/Zap.tsx new file mode 100644 index 00000000..23f6e558 --- /dev/null +++ b/src/element/Zap.tsx @@ -0,0 +1,153 @@ +import "./Zap.css"; +import { useMemo } from "react"; +// @ts-expect-error +import { decode as invoiceDecode } from "light-bolt11-decoder"; +import { bytesToHex } from "@noble/hashes/utils"; + +import { sha256 } from "Util"; +import { formatShort } from "Number"; +import { HexKey, TaggedRawEvent } from "Nostr"; +import Event from "Nostr/Event"; +import Text from "Element/Text"; +import ProfileImage from "Element/ProfileImage"; + +function findTag(e: TaggedRawEvent, tag: string) { + const maybeTag = e.tags.find((evTag) => { + return evTag[0] === tag + }) + return maybeTag && maybeTag[1] +} + +type Section = { + name: string + value?: any + letters?: string +} + +function getSection(sections: Section[], name: string) { + return sections.find((s) => s.name === name) +} + +function getInvoice(zap: TaggedRawEvent) { + const bolt11 = findTag(zap, 'bolt11') + const decoded = invoiceDecode(bolt11) + + const amount = decoded.sections.find((section: any) => section.name === 'amount')?.value + const hash = decoded.sections.find((section: any) => section.name === 'description_hash')?.value; + + return { amount, hash: hash ? bytesToHex(hash) : undefined }; +} + +function getZapper(zap: TaggedRawEvent, dhash: string) { + const zapRequest = findTag(zap, 'description') + if (zapRequest) { + const rawEvent: TaggedRawEvent = JSON.parse(zapRequest); + if (Array.isArray(rawEvent)) { + // old format, ignored + return; + } + const metaHash = sha256(zapRequest); + const ev = new Event(rawEvent) + return { pubkey: ev.PubKey, valid: metaHash == dhash }; + } +} + +interface ParsedZap { + id: HexKey + e?: HexKey + p: HexKey + amount: number + content: string + zapper?: HexKey + valid: boolean +} + +export function parseZap(zap: TaggedRawEvent): ParsedZap { + const { amount, hash } = getInvoice(zap) + const zapper = hash ? getZapper(zap, hash) : { valid: false, pubkey: undefined }; + const e = findTag(zap, 'e') + const p = findTag(zap, 'p')! + return { + id: zap.id, + e, + p, + amount: Number(amount) / 1000, + zapper: zapper?.pubkey, + content: zap.content, + valid: zapper?.valid ?? false, + } +} + +const Zap = ({ zap }: { zap: ParsedZap }) => { + const { amount, content, zapper, valid } = zap + + return valid ? ( +
+
+ {zapper && } +
+ {formatShort(amount)} sats +
+
+
+ +
+
+ ) : null +} + +interface ZapsSummaryProps { zaps: ParsedZap[] } + +export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => { + const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0) + + const topZap = zaps.length > 0 && zaps.reduce((acc, z) => { + return z.amount > acc.amount ? z : acc + }) + const restZaps = zaps.filter(z => topZap && z.id !== topZap.id) + const restZapsTotal = restZaps.reduce((acc, z) => acc + z.amount, 0) + const sortedZaps = useMemo(() => { + const s = [...restZaps] + s.sort((a, b) => b.amount - a.amount) + return s + }, [restZaps]) + const { zapper, amount, content, valid } = topZap || {} + + return ( +
+ {amount && valid && zapper && ( +
+
+ +
+ zapped {formatShort(amount)} sats +
+
+
+ {content && ( + + )} +
+
+ )} + {restZapsTotal > 0 && ( +
+ {restZaps.length} other{restZaps.length > 1 ? 's' : ''} zapped  + {formatShort(restZapsTotal)} sats +
+ )} +
+ ) +} + +export default Zap diff --git a/yarn.lock b/yarn.lock index b6ae8765..35386f6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1569,6 +1569,11 @@ dependencies: eslint-scope "5.1.1" +"@noble/hashes@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.2.0.tgz#a3150eeb09cc7ab207ebf6d7b9ad311a9bdbed12" + integrity sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ== + "@noble/secp256k1@^1.7.0": version "1.7.1" resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.7.1.tgz#b251c70f824ce3ca7f8dc3df08d58f005cc0507c"