diff --git a/packages/app/public/icons.svg b/packages/app/public/icons.svg index 43c7b523..806a5df3 100644 --- a/packages/app/public/icons.svg +++ b/packages/app/public/icons.svg @@ -154,5 +154,9 @@ + + + + \ No newline at end of file diff --git a/packages/app/src/Element/NoteFooter.tsx b/packages/app/src/Element/NoteFooter.tsx index a9f523c4..219738bb 100644 --- a/packages/app/src/Element/NoteFooter.tsx +++ b/packages/app/src/Element/NoteFooter.tsx @@ -10,7 +10,7 @@ import Spinner from "Icons/Spinner"; import { formatShort } from "Number"; import useEventPublisher from "Feed/EventPublisher"; -import { bech32ToHex, delay, normalizeReaction, unwrap } from "Util"; +import { delay, normalizeReaction, unwrap } from "Util"; import { NoteCreator } from "Element/NoteCreator"; import { ReBroadcaster } from "Element/ReBroadcaster"; import Reactions from "Element/Reactions"; @@ -25,13 +25,13 @@ import { reset as resetReBroadcast, } from "State/ReBroadcast"; import useModeration from "Hooks/useModeration"; -import { SnortPubKey, TranslateHost } from "Const"; +import { TranslateHost } from "Const"; import { LNURL } from "LNURL"; -import { DonateLNURL } from "Pages/DonatePage"; import { useWallet } from "Wallet"; import useLogin from "Hooks/useLogin"; import { setBookmarked, setPinned } from "Login"; import { useInteractionCache } from "Hooks/useInteractionCache"; +import { ZapPoolController } from "ZapPoolController"; import messages from "./messages"; @@ -160,7 +160,6 @@ export default function NoteFooter(props: NoteFooterProps) { setZapping(true); try { await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id); - fastZapDonate(); } catch (e) { console.warn("Fast zap failed", e); if (!(e instanceof Error) || e.message !== "User rejected") { @@ -184,26 +183,12 @@ export default function NoteFooter(props: NoteFooterProps) { const zap = handler.canZap && publisher ? await publisher.zap(amount * 1000, key, zr, id) : undefined; const invoice = await handler.getInvoice(amount, undefined, zap); await wallet?.payInvoice(unwrap(invoice.pr)); + ZapPoolController.allocate(amount); await interactionCache.zap(); }); } - function fastZapDonate() { - queueMicrotask(async () => { - if (prefs.fastZapDonate > 0) { - // spin off donate - const donateAmount = Math.floor(prefs.defaultZapAmount * prefs.fastZapDonate); - if (donateAmount > 0) { - console.debug(`Donating ${donateAmount} sats to ${DonateLNURL}`); - fastZapInner(DonateLNURL, donateAmount, bech32ToHex(SnortPubKey)) - .then(() => console.debug("Donation sent! Thank You!")) - .catch(() => console.debug("Failed to donate")); - } - } - }); - } - useEffect(() => { if (prefs.autoZap && !didZap && !isMine && !zapping) { const lnurl = getLNURL(); @@ -212,7 +197,6 @@ export default function NoteFooter(props: NoteFooterProps) { queueMicrotask(async () => { try { await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id); - fastZapDonate(); } catch { // ignored } finally { @@ -457,6 +441,7 @@ export default function NoteFooter(props: NoteFooterProps) { author={author?.pubkey} target={getTargetName()} note={ev.id} + allocatePool={true} />
diff --git a/packages/app/src/Element/SendSats.tsx b/packages/app/src/Element/SendSats.tsx index 6228f7bb..60ac0949 100644 --- a/packages/app/src/Element/SendSats.tsx +++ b/packages/app/src/Element/SendSats.tsx @@ -16,6 +16,7 @@ import { useWallet } from "Wallet"; import useLogin from "Hooks/useLogin"; import { generateRandomKey } from "Login"; import { EventPublisher } from "System/EventPublisher"; +import { ZapPoolController } from "ZapPoolController"; import messages from "./messages"; @@ -36,6 +37,7 @@ export interface SendSatsProps { target?: string; note?: HexKey; author?: HexKey; + allocatePool?: boolean; } export default function SendSats(props: SendSatsProps) { @@ -194,9 +196,12 @@ export default function SendSats(props: SendSatsProps) { async function payWithWallet(invoice: LNURLInvoice) { try { - if (wallet?.isReady) { + if (wallet?.isReady()) { setPaying(true); const res = await wallet.payInvoice(invoice?.pr ?? ""); + if (props.allocatePool) { + ZapPoolController.allocate(amount); + } console.log(res); setSuccess(invoice?.successAction ?? {}); } diff --git a/packages/app/src/Pages/ZapPool.css b/packages/app/src/Pages/ZapPool.css new file mode 100644 index 00000000..611dfd07 --- /dev/null +++ b/packages/app/src/Pages/ZapPool.css @@ -0,0 +1,3 @@ +.zap-pool input[type="range"] { + width: 200px; +} diff --git a/packages/app/src/Pages/ZapPool.tsx b/packages/app/src/Pages/ZapPool.tsx new file mode 100644 index 00000000..7bc46e95 --- /dev/null +++ b/packages/app/src/Pages/ZapPool.tsx @@ -0,0 +1,185 @@ +import "./ZapPool.css"; + +import { useMemo, useSyncExternalStore } from "react"; +import { FormattedMessage, FormattedNumber } from "react-intl"; + +import { SnortPubKey } from "Const"; +import ProfilePreview from "Element/ProfilePreview"; +import useLogin from "Hooks/useLogin"; +import { System } from "System"; +import { UploaderServices } from "Upload"; +import { bech32ToHex, getRelayName, unwrap } from "Util"; +import { ZapPoolController, ZapPoolRecipient, ZapPoolRecipientType } from "ZapPoolController"; +import { useUserProfile } from "Hooks/useUserProfile"; +import AsyncButton from "Element/AsyncButton"; +import { useWallet } from "Wallet"; + +function ZapTarget({ target }: { target: ZapPoolRecipient }) { + const login = useLogin(); + const profile = useUserProfile(target.pubkey); + const hasAddress = profile?.lud16 || profile?.lud06; + const defaultZapMount = Math.ceil(login.preferences.defaultZapAmount * (target.split / 100)); + return ( + +
+ % ( + ) +
+ + ZapPoolController.set({ + ...target, + split: e.target.valueAsNumber, + }) + } + /> +
+ ) : ( + + ) + } + /> + ); +} + +export default function ZapPoolPage() { + const login = useLogin(); + const zapPool = useSyncExternalStore( + c => ZapPoolController.hook(c), + () => ZapPoolController.snapshot() + ); + const { wallet } = useWallet(); + + const relayConnections = useMemo(() => { + return [...System.Sockets.values()] + .map(a => { + if (a.Info?.pubkey) { + return { + address: a.Address, + pubkey: a.Info.pubkey, + }; + } + }) + .filter(a => a !== undefined) + .map(unwrap); + }, [login.relays]); + + const sumPending = zapPool.reduce((acc, v) => acc + v.sum, 0); + return ( +
+

+ +

+

+ +

+

+ +

+

+ + + + ), + }} + /> +

+

+ + + + ), + nOut: ( + + + + ), + }} + /> +

+

+ + + + ), + }} + /> +

+

+ {wallet && ( + ZapPoolController.payout(wallet)}> + + + )} +

+
+ b.pubkey === bech32ToHex(SnortPubKey) && b.type === ZapPoolRecipientType.Generic) ?? { + type: ZapPoolRecipientType.Generic, + pubkey: bech32ToHex(SnortPubKey), + split: 0, + sum: 0, + } + } + /> +
+

+ +

+ {relayConnections.map(a => ( +
+

{getRelayName(a.address)}

+ b.pubkey === a.pubkey && b.type === ZapPoolRecipientType.Relay) ?? { + type: ZapPoolRecipientType.Relay, + pubkey: a.pubkey, + split: 0, + sum: 0, + } + } + /> +
+ ))} +

+ +

+ {UploaderServices.map(a => ( +
+

{a.name}

+ b.pubkey === a.owner && b.type === ZapPoolRecipientType.FileHost) ?? { + type: ZapPoolRecipientType.FileHost, + pubkey: a.owner, + split: 0, + sum: 0, + } + } + /> +
+ ))} +
+ ); +} diff --git a/packages/app/src/Pages/settings/Index.tsx b/packages/app/src/Pages/settings/Index.tsx index 7ccc5d02..7c929d41 100644 --- a/packages/app/src/Pages/settings/Index.tsx +++ b/packages/app/src/Pages/settings/Index.tsx @@ -83,7 +83,11 @@ const SettingsIndex = () => { - +
navigate("/zap-pool")}> + + + +
diff --git a/packages/app/src/Upload/index.ts b/packages/app/src/Upload/index.ts index a93b67ec..9f286009 100644 --- a/packages/app/src/Upload/index.ts +++ b/packages/app/src/Upload/index.ts @@ -4,6 +4,8 @@ import { RawEvent } from "@snort/nostr"; import NostrBuild from "Upload/NostrBuild"; import VoidCat from "Upload/VoidCat"; import NostrImg from "Upload/NostrImg"; +import { KieranPubKey } from "Const"; +import { bech32ToHex } from "Util"; export interface UploadResult { url?: string; @@ -15,6 +17,24 @@ export interface UploadResult { header?: RawEvent; } +/** + * List of supported upload services and their owners on nostr + */ +export const UploaderServices = [ + { + name: "void.cat", + owner: bech32ToHex(KieranPubKey), + }, + { + name: "nostr.build", + owner: bech32ToHex("npub1nxy4qpqnld6kmpphjykvx2lqwvxmuxluddwjamm4nc29ds3elyzsm5avr7"), + }, + { + name: "nostrimg.com", + owner: bech32ToHex("npub1xv6axulxcx6mce5mfvfzpsy89r4gee3zuknulm45cqqpmyw7680q5pxea6"), + }, +]; + export interface Uploader { upload: (f: File | Blob, filename: string) => Promise; } diff --git a/packages/app/src/ZapPoolController.ts b/packages/app/src/ZapPoolController.ts new file mode 100644 index 00000000..d02dc997 --- /dev/null +++ b/packages/app/src/ZapPoolController.ts @@ -0,0 +1,124 @@ +import { UserCache } from "Cache"; +import ExternalStore from "ExternalStore"; +import { LNURL } from "LNURL"; +import { LNWallet, WalletInvoiceState } from "Wallet"; + +export enum ZapPoolRecipientType { + Generic = 0, + Relay = 1, + FileHost = 2, +} + +export interface ZapPoolRecipient { + type: ZapPoolRecipientType; + pubkey: string; + split: number; + sum: number; +} + +class ZapPool extends ExternalStore> { + #store: Map; + #isPayoutInProgress = false; + + constructor() { + super(); + this.#store = new Map(); + this.#load(); + } + + async payout(wallet: LNWallet) { + if (this.#isPayoutInProgress) { + throw new Error("Payout already in progress"); + } + this.#isPayoutInProgress = true; + for (const x of this.#store.values()) { + if (x.sum === 0) continue; + try { + const profile = await UserCache.get(x.pubkey); + if (!profile) { + throw new Error(`Failed to get profile for ${x.pubkey}`); + } + const svc = new LNURL(profile.lud16 || profile.lud06 || ""); + await svc.load(); + const amtSend = x.sum; + const invoice = await svc.getInvoice(amtSend, `SnortZapPool: ${x.split}%`); + if (invoice.pr) { + const result = await wallet.payInvoice(invoice.pr); + if (result.state === WalletInvoiceState.Paid) { + x.sum -= amtSend; + } else { + throw new Error("Payment failed"); + } + } else { + throw new Error(invoice.reason ?? "Failed to get invoice"); + } + } catch (e) { + console.error(e); + } + } + this.#save(); + this.notifyChange(); + this.#isPayoutInProgress = false; + } + + calcAllocation(n: number) { + let res = 0; + for (const x of this.#store.values()) { + res += Math.ceil(n * (x.split / 100)); + } + return res; + } + + allocate(n: number) { + if (this.#isPayoutInProgress) { + throw new Error("Payout is in progress, cannot allocate to pool"); + } + for (const x of this.#store.values()) { + x.sum += Math.ceil(n * (x.split / 100)); + } + this.#save(); + this.notifyChange(); + } + + getOrDefault(rcpt: ZapPoolRecipient) { + const k = this.#key(rcpt); + if (this.#store.has(k)) { + return { ...this.#store.get(k) }; + } + return rcpt; + } + + set(rcpt: ZapPoolRecipient) { + const k = this.#key(rcpt); + // delete entry if split is 0 and sum is 0 + if (rcpt.split === 0 && rcpt.sum === 0 && this.#store.has(k)) { + this.#store.delete(k); + } else { + this.#store.set(k, rcpt); + } + this.#save(); + this.notifyChange(); + } + + #key(rcpt: ZapPoolRecipient) { + return `${rcpt.pubkey}-${rcpt.type}`; + } + + #save() { + self.localStorage.setItem("zap-pool", JSON.stringify(this.takeSnapshot())); + } + + #load() { + const existing = self.localStorage.getItem("zap-pool"); + if (existing) { + const arr = JSON.parse(existing) as Array; + this.#store = new Map(arr.map(a => [`${a.pubkey}-${a.type}`, a])); + } + } + + takeSnapshot(): ZapPoolRecipient[] { + return [...this.#store.values()]; + } +} + +export const ZapPoolController = new ZapPool(); diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index 788af705..2268c1fd 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -30,7 +30,7 @@ import { WalletRoutes } from "Pages/WalletPage"; import NostrLinkHandler from "Pages/NostrLinkHandler"; import Thread from "Element/Thread"; import { SubscribeRoutes } from "Pages/subscribe"; -import Discover from "Pages/Discover"; +import ZapPoolPage from "Pages/ZapPool"; /** * HTTP query provider @@ -94,6 +94,10 @@ export const router = createBrowserRouter([ path: "/search/:keyword?", element: , }, + { + path: "/zap-pool", + element: , + }, ...NewUserRoutes, ...WalletRoutes, ...SubscribeRoutes,