Merge pull request #78 from v0l/zaps

feat: zaps
This commit is contained in:
Kieran 2023-02-04 13:53:59 +00:00 committed by GitHub
commit 529074e0c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 372 additions and 22 deletions

View File

@ -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",

View File

@ -103,4 +103,4 @@ export default function HyperText({ link, creator }: { link: string, creator: He
}, [link]);
return render();
}
}

View File

@ -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<LNURLService>();
const [amount, setAmount] = useState<number>();
@ -46,6 +53,7 @@ export default function LNURLTip(props: LNURLTipProps) {
const [error, setError] = useState<string>();
const [success, setSuccess] = useState<LNURLSuccessAction>();
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) {
</div>
</Modal>
)
}
}

View File

@ -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 (
<>
<div className="reaction-pill" onClick={() => setTip(true)}>
<div className={`reaction-pill ${didZap ? 'reacted' : ''}`} onClick={() => setTip(true)}>
<div className="reaction-pill-icon">
<Zap />
</div>
{zapTotal > 0 && (<div className="reaction-pill-number">{formatShort(zapTotal)}</div>)}
</div>
</>
)
@ -233,6 +238,7 @@ export default function NoteFooter(props: NoteFooterProps) {
}
return (
<>
<div className="footer">
<div className="footer-reactions">
{tipButton()}
@ -259,7 +265,11 @@ export default function NoteFooter(props: NoteFooterProps) {
show={reply}
setShow={setReply}
/>
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={() => setTip(false)} show={tip} />
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={() => setTip(false)} show={tip} author={author?.pubkey} note={ev.Id} />
</div>
<div className="zaps-container">
<ZapsSummary zaps={zaps} />
</div>
</>
)
}

View File

@ -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";
@ -45,15 +46,19 @@ export default function Timeline({ subject, postsOnly = false, method, ignoreMod
function eventElement(e: TaggedRawEvent) {
switch (e.kind) {
case EventKind.SetMetadata: {
return <ProfilePreview pubkey={e.pubkey} className="card"/>
return <ProfilePreview pubkey={e.pubkey} className="card" />
}
case EventKind.TextNote: {
return <Note key={e.id} data={e} related={related.notes} ignoreModeration={ignoreModeration} />
}
case EventKind.ZapReceipt: {
const zap = parseZap(e)
return zap.e ? null : <Zap zap={zap} key={e.id} />
}
case EventKind.Reaction:
case EventKind.Repost: {
let eRef = e.tags.find(a => a[0] === "e")?.at(1);
return <NoteReaction data={e} key={e.id} root={parent.notes.find(a => a.id === eRef)}/>
return <NoteReaction data={e} key={e.id} root={parent.notes.find(a => a.id === eRef)} />
}
}
}
@ -61,12 +66,12 @@ export default function Timeline({ subject, postsOnly = false, method, ignoreMod
return (
<div className="main-content">
{latestFeed.length > 1 && (<div className="card latest-notes pointer" onClick={() => showLatest()}>
<FontAwesomeIcon icon={faForward} size="xl"/>
<FontAwesomeIcon icon={faForward} size="xl" />
&nbsp;
Show latest {latestFeed.length - 1} notes
</div>)}
{mainFeed.map(eventElement)}
<LoadMore onLoadMore={loadMore} shouldLoadMore={main.end}/>
<LoadMore onLoadMore={loadMore} shouldLoadMore={main.end} />
</div>
);
}

84
src/Element/Zap.css Normal file
View File

@ -0,0 +1,84 @@
.zap {
min-height: unset;
}
@media (max-width: 520px) {
.zap .header {
flex-direction: column;
}
.zap .header .pfp {
width: 100%;
padding: 4px;
}
.zap .header .amount {
font-size: 32px;
}
}
.zap .summary {
display: flex;
align-items: center;
justify-content: space-between;
}
.zap .amount {
font-size: 18px;
}
.zap .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: 14px;
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 .nip05 {
display: none;
}
.amount-number {
font-weight: bold;
}
.rest-zaps:before {
content: ", ";
}
.note.zap > .header {
align-items: center;
}
.note.zap > .body {
margin-bottom: 0;
}

155
src/Element/Zap.tsx Normal file
View File

@ -0,0 +1,155 @@
import "./Zap.css";
import { useMemo } from "react";
import { useSelector } from "react-redux";
// @ts-expect-error
import { decode as invoiceDecode } from "light-bolt11-decoder";
import { bytesToHex } from "@noble/hashes/utils";
import { sha256 } from "Util";
//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";
import { RootState } from "State/Store";
function findTag(e: TaggedRawEvent, tag: string) {
const maybeTag = e.tags.find((evTag) => {
return evTag[0] === tag
})
return maybeTag && maybeTag[1]
}
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 };
}
interface Zapper {
pubkey?: HexKey,
isValid: boolean
}
function getZapper(zap: TaggedRawEvent, dhash: string): Zapper {
const zapRequest = findTag(zap, 'description')
if (zapRequest) {
const rawEvent: TaggedRawEvent = JSON.parse(zapRequest);
if (Array.isArray(rawEvent)) {
// old format, ignored
return { isValid: false };
}
const metaHash = sha256(zapRequest);
const ev = new Event(rawEvent)
return { pubkey: ev.PubKey, isValid: dhash === metaHash };
}
return { isValid: false }
}
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) : { isValid: false };
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.isValid,
}
}
const Zap = ({ zap, showZapped = true }: { zap: ParsedZap, showZapped?: boolean }) => {
const { amount, content, zapper, valid, p } = zap
const pubKey = useSelector((s: RootState) => s.login.publicKey)
return valid ? (
<div className="zap note card">
<div className="header">
{zapper ? <ProfileImage pubkey={zapper} /> : <div>Anon&nbsp;</div>}
{p !== pubKey && showZapped && <ProfileImage pubkey={p} />}
<div className="amount">
<span className="amount-number">{formatShort(amount)}</span> sats
</div>
</div>
<div className="body">
<Text
creator={zapper || ""}
content={content}
tags={[]}
users={new Map()}
/>
</div>
</div>
) : null
}
interface ZapsSummaryProps { zaps: ParsedZap[] }
export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
const sortedZaps = useMemo(() => {
const pub = [...zaps.filter(z => z.zapper)]
const priv = [...zaps.filter(z => !z.zapper)]
pub.sort((a, b) => b.amount - a.amount)
return pub.concat(priv)
}, [zaps])
if (zaps.length === 0) {
return null
}
const [topZap, ...restZaps] = sortedZaps
const restZapsTotal = restZaps.reduce((acc, z) => acc + z.amount, 0)
const { zapper, amount, content, valid } = topZap
return (
<div className="zaps-summary">
{amount && (
<div className={`top-zap`}>
<div className="summary">
{zapper ? <ProfileImage pubkey={zapper} /> : <div>Anon&nbsp;</div>}
<div className="amount">
zapped <span className="amount-number">{formatShort(amount)}</span> sats
</div>
</div>
<div className="body">
{content && (
<Text
creator={zapper || ""}
content={content}
tags={[]}
users={new Map()}
/>
)}
</div>
</div>
)}
{restZapsTotal > 0 && (
<div className="rest-zaps">
{restZaps.length} other{restZaps.length > 1 ? 's' : ''} zapped&nbsp;
<span className="amount-number">{formatShort(restZapsTotal)}</span> sats
</div>
)}
</div>
)
}
export default Zap

View File

@ -19,7 +19,7 @@ const ZapButton = ({ pubkey, svc }: { pubkey?: HexKey, svc?: string }) => {
<div className="zap-button" onClick={(e) => setZap(true)}>
<FontAwesomeIcon icon={faBolt} />
</div>
<LNURLTip svc={service} show={zap} onClose={() => setZap(false)} />
<LNURLTip svc={service} show={zap} onClose={() => setZap(false)} author={pubkey} />
</>
)
}

View File

@ -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).slice(0, 10)]
// @ts-ignore
ev.Tags.push(new Tag(relayTag))
processContent(ev, msg || '');
return await signEvent(ev);
}
},
/**
* Reply to a note
*/

View File

@ -41,6 +41,7 @@ export default function useLoginFeed() {
let sub = new Subscriptions();
sub.Id = "login:notifications";
// todo: add zaps
sub.Kinds = new Set([EventKind.TextNote]);
sub.PTags = new Set([pubKey]);
sub.Limit = 1;

View File

@ -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;
}
}

View File

@ -35,7 +35,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
let sub = new Subscriptions();
sub.Id = `timeline:${subject.type}:${subject.discriminator}`;
sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]);
sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost, EventKind.ZapReceipt]);
switch (subject.type) {
case "pubkey": {
sub.Authors = new Set(subject.items);
@ -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;

17
src/Feed/ZapsFeed.ts Normal file
View File

@ -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 });
}

View File

@ -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;

View File

@ -4,11 +4,11 @@ const intl = new Intl.NumberFormat("en", {
});
export function formatShort(n: number) {
if (n < 999) {
if (n < 2e3) {
return n
} else if (n < 1e8) {
return `${intl.format(Math.floor(n / 1e3))}K`
return `${intl.format(n / 1e3)}K`
} else {
return `${intl.format(Math.floor(n / 1e6))}M`
return `${intl.format(n / 1e6)}M`
}
}

View File

@ -71,7 +71,10 @@ const DonatePage = () => {
</p>
<div className="flex">
<div className="mr10">Lightning Donation: </div>
<ZapButton svc={"donate@snort.social"} />
<ZapButton
pubkey={"84de35e2584d2b144aae823c9ed0b0f3deda09648530b93d1a2a146d1dea9864"}
svc={"donate@snort.social"}
/>
</div>
{today && (<small>Total today (UTC): {today.donations.toLocaleString()} sats</small>)}
<h3>Primary Developers</h3>

View File

@ -213,3 +213,8 @@
.qr-modal .modal-body {
width: unset;
}
.profile .zap-amount {
font-weight: normal;
margin-left: 4px;
}

View File

@ -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 { default as ZapElement, parseZap } from "Element/Zap";
import FollowButton from "Element/FollowButton";
import { extractLnAddress, parseId, hexToBech32 } from "Util";
import Avatar from "Element/Avatar";
@ -36,6 +39,7 @@ enum ProfileTab {
Reactions = "Reactions",
Followers = "Followers",
Follows = "Follows",
Zaps = "Zaps",
Muted = "Muted",
Blocked = "Blocked"
};
@ -58,6 +62,13 @@ 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(() => {
const profileZaps = zapFeed.store.notes.map(parseZap).filter(z => z.valid && z.p === id && !z.e && z.zapper !== id)
profileZaps.sort((a, b) => b.amount - a.amount)
return profileZaps
}, [zapFeed.store.notes, id])
const zapsTotal = zaps.reduce((acc, z) => acc + z.amount, 0)
useEffect(() => {
setTab(ProfileTab.Notes);
@ -89,7 +100,7 @@ export default function ProfilePage() {
</div>
)}
<LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} />
<LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} author={id} />
</div>
)
}
@ -109,6 +120,14 @@ export default function ProfilePage() {
switch (tab) {
case ProfileTab.Notes:
return <Timeline key={id} subject={{ type: "pubkey", items: [id], discriminator: id.slice(0, 12) }} postsOnly={false} method={"LIMIT_UNTIL"} ignoreModeration={true} />;
case ProfileTab.Zaps: {
return (
<div className="main-content">
{zaps.map(z => <ZapElement showZapped={false} zap={z} />)}
</div>
)
}
case ProfileTab.Follows: {
if (isMe) {
return (
@ -163,6 +182,9 @@ export default function ProfilePage() {
<>
<IconButton onClick={() => setShowLnQr(true)}>
<Zap width={14} height={16} />
<span className="zap-amount">
{zapsTotal > 0 && formatShort(zapsTotal)}
</span>
</IconButton>
{!loggedOut && (
<>
@ -205,7 +227,7 @@ export default function ProfilePage() {
</div>
</div>
<div className="tabs">
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(renderTab)}
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Zaps, ProfileTab.Muted].map(renderTab)}
{isMe && renderTab(ProfileTab.Blocked)}
</div>
{tabContent()}

View File

@ -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<File | undefined> {
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);
}
}

View File

@ -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"