feat: zaps

This commit is contained in:
Alejandro Gomez 2023-02-03 22:38:14 +01:00
parent d1087d0405
commit eb77e91b57
No known key found for this signature in database
GPG Key ID: 4DF39E566658C817
17 changed files with 341 additions and 12 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>
</>
)
@ -259,7 +264,7 @@ 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>
)
}

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";
@ -50,6 +51,9 @@ export default function Timeline({ subject, postsOnly = false, method, ignoreMod
case EventKind.TextNote: {
return <Note key={e.id} data={e} related={related.notes} ignoreModeration={ignoreModeration} />
}
case EventKind.ZapReceipt: {
return <Zap zap={parseZap(e)} />
}
case EventKind.Reaction:
case EventKind.Repost: {
let eRef = e.tags.find(a => a[0] === "e")?.at(1);

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

View File

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

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

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

@ -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 { 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() {
</div>
)}
<LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} />
<LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} author={id} />
</div>
)
}
@ -163,6 +171,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 && (
<>

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

86
src/element/Zap.css Normal file
View File

@ -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: ", ";
}

153
src/element/Zap.tsx Normal file
View File

@ -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 ? (
<div className="zap">
<div className="summary">
{zapper && <ProfileImage pubkey={zapper} />}
<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 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 (
<div className="zaps-summary">
{amount && valid && zapper && (
<div className={`top-zap`}>
<div className="summary">
<ProfileImage pubkey={zapper} />
<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

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