From d2baf9bd5b6af4fd8824f15f77f280572cf36deb Mon Sep 17 00:00:00 2001 From: Kieran Date: Thu, 14 Sep 2023 12:31:17 +0100 Subject: [PATCH] Zap splits --- packages/app/src/Element/Avatar.css | 10 + packages/app/src/Element/Avatar.tsx | 11 +- packages/app/src/Element/Copy.css | 11 +- packages/app/src/Element/Copy.tsx | 4 +- packages/app/src/Element/Invoice.tsx | 2 +- packages/app/src/Element/NoteCreator.tsx | 204 ++++++-- packages/app/src/Element/NoteFooter.tsx | 87 +-- packages/app/src/Element/ProfileImage.tsx | 6 +- packages/app/src/Element/SendSats.css | 125 +---- packages/app/src/Element/SendSats.tsx | 612 ++++++++++++---------- packages/app/src/Element/Tabs.css | 1 - packages/app/src/Element/Tabs.tsx | 2 +- packages/app/src/Element/ZapButton.tsx | 3 +- packages/app/src/Element/messages.ts | 7 - packages/app/src/Pages/Discover.tsx | 2 +- packages/app/src/Pages/DonatePage.tsx | 2 + packages/app/src/Pages/ProfilePage.tsx | 9 +- packages/app/src/Pages/SearchPage.tsx | 2 +- packages/app/src/State/NoteCreator.ts | 12 +- packages/app/src/Zapper.ts | 208 ++++++++ packages/app/src/index.css | 12 + packages/app/src/lang.json | 63 ++- packages/app/src/translations/en.json | 17 +- packages/shared/src/lnurl.ts | 11 - packages/shared/src/utils.ts | 4 + packages/system/src/event-builder.ts | 12 +- packages/system/src/nostr-link.ts | 11 + packages/system/src/profile-cache.ts | 19 + 28 files changed, 907 insertions(+), 562 deletions(-) create mode 100644 packages/app/src/Zapper.ts diff --git a/packages/app/src/Element/Avatar.css b/packages/app/src/Element/Avatar.css index 178f52d4..b61f7e13 100644 --- a/packages/app/src/Element/Avatar.css +++ b/packages/app/src/Element/Avatar.css @@ -18,3 +18,13 @@ .avatar[data-domain="strike.army"] { background-image: var(--img-url), var(--strike-army-gradient); } + +.avatar .overlay { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0.4); +} diff --git a/packages/app/src/Element/Avatar.tsx b/packages/app/src/Element/Avatar.tsx index e3a85162..4c78aa42 100644 --- a/packages/app/src/Element/Avatar.tsx +++ b/packages/app/src/Element/Avatar.tsx @@ -1,6 +1,6 @@ import "./Avatar.css"; -import { CSSProperties, useEffect, useState } from "react"; +import { CSSProperties, ReactNode, useEffect, useState } from "react"; import type { UserMetadata } from "@snort/system"; import useImgProxy from "Hooks/useImgProxy"; @@ -13,8 +13,9 @@ interface AvatarProps { onClick?: () => void; size?: number; image?: string; + imageOverlay?: ReactNode; } -const Avatar = ({ pubkey, user, size, onClick, image }: AvatarProps) => { +const Avatar = ({ pubkey, user, size, onClick, image, imageOverlay }: AvatarProps) => { const [url, setUrl] = useState(""); const { proxy } = useImgProxy(); @@ -35,9 +36,11 @@ const Avatar = ({ pubkey, user, size, onClick, image }: AvatarProps) => {
+ title={getDisplayName(user, "")}> + {imageOverlay &&
{imageOverlay}
} + ); }; diff --git a/packages/app/src/Element/Copy.css b/packages/app/src/Element/Copy.css index 22da429e..a85f8aaa 100644 --- a/packages/app/src/Element/Copy.css +++ b/packages/app/src/Element/Copy.css @@ -1,14 +1,5 @@ -.copy { - cursor: pointer; - align-items: center; -} - -.copy .body { +.copy .copy-body { font-size: var(--font-size-small); color: var(--font-color); margin-right: 6px; } - -.copy .icon { - margin-bottom: -4px; -} diff --git a/packages/app/src/Element/Copy.tsx b/packages/app/src/Element/Copy.tsx index e2520eb1..c267af6b 100644 --- a/packages/app/src/Element/Copy.tsx +++ b/packages/app/src/Element/Copy.tsx @@ -13,8 +13,8 @@ export default function Copy({ text, maxSize = 32, className }: CopyProps) { const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text; return ( -
copy(text)}> - {trimmed} +
copy(text)}> + {trimmed} {copied ? : } diff --git a/packages/app/src/Element/Invoice.tsx b/packages/app/src/Element/Invoice.tsx index 31700cd8..a679517d 100644 --- a/packages/app/src/Element/Invoice.tsx +++ b/packages/app/src/Element/Invoice.tsx @@ -75,7 +75,7 @@ export default function Invoice(props: InvoiceProps) { {description &&

{description}

} {isPaid ? (
- +
) : (

- +

- - dispatch(setZapForward(e.target.value))} - /> + +
+ {[...(zapSplits ?? [])].map((v, i, arr) => ( +
+
+

+ +

+ + dispatch( + setZapSplits(arr.map((vv, ii) => (ii === i ? { ...vv, value: e.target.value } : vv))), + ) + } + placeholder={formatMessage({ defaultMessage: "npub / nprofile / nostr address" })} + /> +
+
+

+ +

+ + dispatch( + setZapSplits( + arr.map((vv, ii) => (ii === i ? { ...vv, weight: Number(e.target.value) } : vv)), + ), + ) + } + /> +
+
+
 
+ dispatch(setZapSplits((zapSplits ?? []).filter((_v, ii) => ii !== i)))} + /> +
+
+ ))} + +
- +
diff --git a/packages/app/src/Element/NoteFooter.tsx b/packages/app/src/Element/NoteFooter.tsx index a1b03b99..00f7bc11 100644 --- a/packages/app/src/Element/NoteFooter.tsx +++ b/packages/app/src/Element/NoteFooter.tsx @@ -1,14 +1,13 @@ -import React, { HTMLProps, useEffect, useState } from "react"; +import React, { HTMLProps, useContext, useEffect, useState } from "react"; import { useSelector, useDispatch } from "react-redux"; import { useIntl } from "react-intl"; import { useLongPress } from "use-long-press"; -import { TaggedNostrEvent, HexKey, u256, ParsedZap, countLeadingZeros } from "@snort/system"; -import { LNURL } from "@snort/shared"; -import { useUserProfile } from "@snort/system-react"; +import { TaggedNostrEvent, ParsedZap, countLeadingZeros, createNostrLinkToEvent } from "@snort/system"; +import { SnortContext, useUserProfile } from "@snort/system-react"; import { formatShort } from "Number"; import useEventPublisher from "Feed/EventPublisher"; -import { delay, findTag, normalizeReaction, unwrap } from "SnortUtils"; +import { delay, findTag, normalizeReaction } from "SnortUtils"; import { NoteCreator } from "Element/NoteCreator"; import SendSats from "Element/SendSats"; import { ZapsSummary } from "Element/Zap"; @@ -21,6 +20,8 @@ import useLogin from "Hooks/useLogin"; import { useInteractionCache } from "Hooks/useInteractionCache"; import { ZapPoolController } from "ZapPoolController"; import { System } from "index"; +import { Zapper, ZapTarget } from "Zapper"; +import { getDisplayName } from "./ProfileImage"; import messages from "./messages"; @@ -47,9 +48,10 @@ export interface NoteFooterProps { export default function NoteFooter(props: NoteFooterProps) { const { ev, positive, reposts, zaps } = props; const dispatch = useDispatch(); + const system = useContext(SnortContext); const { formatMessage } = useIntl(); const login = useLogin(); - const { publicKey, preferences: prefs, relays } = login; + const { publicKey, preferences: prefs } = login; const author = useUserProfile(ev.pubkey); const interactionCache = useInteractionCache(publicKey, ev.id); const publisher = useEventPublisher(); @@ -103,31 +105,36 @@ export default function NoteFooter(props: NoteFooterProps) { } } - function getLNURL() { - return ev.tags.find(a => a[0] === "zap")?.[1] || author?.lud16 || author?.lud06; - } + function getZapTarget(): Array | undefined { + if (ev.tags.some(v => v[0] === "zap")) { + return Zapper.fromEvent(ev); + } - function getTargetName() { - const zapTarget = ev.tags.find(a => a[0] === "zap")?.[1]; - if (zapTarget) { - try { - return new LNURL(zapTarget).name; - } catch { - // ignore - } - } else { - return author?.display_name || author?.name; + const authorTarget = author?.lud16 || author?.lud06; + if (authorTarget) { + return [ + { + type: "lnurl", + value: authorTarget, + weight: 1, + name: getDisplayName(author, ev.pubkey), + zap: { + pubkey: ev.pubkey, + event: createNostrLinkToEvent(ev), + }, + } as ZapTarget, + ]; } } async function fastZap(e?: React.MouseEvent) { if (zapping || e?.isPropagationStopped()) return; - const lnurl = getLNURL(); + const lnurl = getZapTarget(); if (wallet?.isReady() && lnurl) { setZapping(true); try { - await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id); + await fastZapInner(lnurl, prefs.defaultZapAmount); } catch (e) { console.warn("Fast zap failed", e); if (!(e instanceof Error) || e.message !== "User rejected") { @@ -141,30 +148,29 @@ export default function NoteFooter(props: NoteFooterProps) { } } - async function fastZapInner(lnurl: string, amount: number, key: HexKey, id?: u256) { - // only allow 1 invoice req/payment at a time to avoid hitting rate limits - await barrierZapper(async () => { - const handler = new LNURL(lnurl); - await handler.load(); - - const zr = Object.keys(relays.item); - 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(); - }); + async function fastZapInner(targets: Array, amount: number) { + if (wallet) { + // only allow 1 invoice req/payment at a time to avoid hitting rate limits + await barrierZapper(async () => { + const zapper = new Zapper(system, publisher); + const result = await zapper.send(wallet, targets, amount); + const totalSent = result.reduce((acc, v) => (acc += v.sent), 0); + if (totalSent > 0) { + ZapPoolController.allocate(totalSent); + await interactionCache.zap(); + } + }); + } } useEffect(() => { if (prefs.autoZap && !didZap && !isMine && !zapping) { - const lnurl = getLNURL(); + const lnurl = getZapTarget(); if (wallet?.isReady() && lnurl) { setZapping(true); queueMicrotask(async () => { try { - await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id); + await fastZapInner(lnurl, prefs.defaultZapAmount); } catch { // ignored } finally { @@ -185,8 +191,8 @@ export default function NoteFooter(props: NoteFooterProps) { } function tipButton() { - const service = getLNURL(); - if (service) { + const targets = getZapTarget(); + if (targets) { return ( {willRenderNoteCreator && } setTip(false)} show={tip} author={author?.pubkey} - target={getTargetName()} note={ev.id} allocatePool={true} /> diff --git a/packages/app/src/Element/ProfileImage.tsx b/packages/app/src/Element/ProfileImage.tsx index 1e2244b7..c906dd63 100644 --- a/packages/app/src/Element/ProfileImage.tsx +++ b/packages/app/src/Element/ProfileImage.tsx @@ -1,6 +1,6 @@ import "./ProfileImage.css"; -import React, { useMemo } from "react"; +import React, { ReactNode, useMemo } from "react"; import { Link } from "react-router-dom"; import { HexKey, NostrPrefix, UserMetadata } from "@snort/system"; import { useUserProfile } from "@snort/system-react"; @@ -21,6 +21,7 @@ export interface ProfileImageProps { profile?: UserMetadata; size?: number; onClick?: (e: React.MouseEvent) => void; + imageOverlay?: ReactNode; } export default function ProfileImage({ @@ -34,6 +35,7 @@ export default function ProfileImage({ overrideUsername, profile, size, + imageOverlay, onClick, }: ProfileImageProps) { const user = useUserProfile(profile ? "" : pubkey) ?? profile; @@ -54,7 +56,7 @@ export default function ProfileImage({ return ( <>
- +
{showUsername && (
diff --git a/packages/app/src/Element/SendSats.css b/packages/app/src/Element/SendSats.css index f686f06e..48328398 100644 --- a/packages/app/src/Element/SendSats.css +++ b/packages/app/src/Element/SendSats.css @@ -1,31 +1,32 @@ .lnurl-modal .modal-body { - padding: 0; - max-width: 470px; + padding: 12px 24px; + max-width: 500px; } -.lnurl-modal .lnurl-tip .pfp .avatar { +.lnurl-modal .pfp .avatar { width: 48px; height: 48px; } -.lnurl-tip { - padding: 24px 32px; - background-color: #1b1b1b; - border-radius: 16px; - position: relative; -} - @media (max-width: 720px) { - .lnurl-tip { + .lnurl-modal { padding: 12px 16px; } } -.light .lnurl-tip { +.light .lnurl-modal { background-color: var(--gray-superdark); } -.lnurl-tip h3 { +.lnurl-modal h2 { + margin: 0; + font-weight: 600; + font-size: 16px; + line-height: 19px; +} + +.lnurl-modal h3 { + margin: 0; color: var(--font-secondary-color); font-size: 11px; letter-spacing: 0.11em; @@ -34,43 +35,14 @@ text-transform: uppercase; } -.lnurl-tip .close { - position: absolute; - top: 12px; - right: 16px; - color: var(--font-secondary-color); - cursor: pointer; -} - -.lnurl-tip .close:hover { - color: var(--font-tertiary-color); -} - -.lnurl-tip .lnurl-header { - display: flex; - flex-direction: row; - align-items: center; - margin-bottom: 32px; -} - -.lnurl-tip .lnurl-header h2 { - margin: 0; - flex-grow: 1; - font-weight: 600; - font-size: 16px; - line-height: 19px; -} - .amounts { - display: flex; - width: 100%; - margin-bottom: 16px; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; } .sat-amount { - flex: 1 1 auto; text-align: center; - display: inline-block; background-color: #2a2a2a; color: var(--font-color); padding: 12px 16px; @@ -79,83 +51,28 @@ font-weight: 600; font-size: 14px; line-height: 17px; + cursor: pointer; } .light .sat-amount { background-color: var(--gray); } -.sat-amount:not(:last-child) { - margin-right: 8px; -} - -.sat-amount:hover { - cursor: pointer; -} - .sat-amount.active { font-weight: bold; color: var(--gray-superdark); background-color: var(--font-color); } -.lnurl-tip .invoice { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} - -.lnurl-tip .invoice .actions { - display: flex; - flex-direction: column; - align-items: flex-start; - text-align: center; -} - -.lnurl-tip .invoice .actions .copy-action { - margin: 10px auto; -} - -.lnurl-tip .invoice .actions .wallet-action { - width: 100%; - height: 40px; -} - -.lnurl-tip .zap-action { - margin-top: 16px; - width: 100%; - height: 40px; -} - -.lnurl-tip .zap-action svg { - margin-right: 10px; -} - -.lnurl-tip .zap-action-container { - display: flex; - align-items: center; - justify-content: center; -} - -.lnurl-tip .custom-amount { - margin-bottom: 16px; -} - -.lnurl-tip .custom-amount button { - padding: 12px 18px; - border-radius: 100px; -} - -.lnurl-tip canvas { +.lnurl-modal canvas { border-radius: 10px; } -.lnurl-tip .success-action .paid { +.lnurl-modal .success-action .paid { font-size: 19px; } -.lnurl-tip .success-action a { +.lnurl-modal .success-action a { color: var(--highlight); font-size: 19px; } diff --git a/packages/app/src/Element/SendSats.tsx b/packages/app/src/Element/SendSats.tsx index 1c0823f0..e06611ba 100644 --- a/packages/app/src/Element/SendSats.tsx +++ b/packages/app/src/Element/SendSats.tsx @@ -1,9 +1,10 @@ import "./SendSats.css"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { ReactNode, useContext, useEffect, useState } from "react"; import { useIntl, FormattedMessage } from "react-intl"; -import { HexKey, NostrEvent, EventPublisher } from "@snort/system"; -import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "@snort/shared"; +import { HexKey } from "@snort/system"; +import { SnortContext } from "@snort/system-react"; +import { LNURLSuccessAction } from "@snort/shared"; import { formatShort } from "Number"; import Icon from "Icons/Icon"; @@ -12,12 +13,11 @@ import ProfileImage from "Element/ProfileImage"; import Modal from "Element/Modal"; import QrCode from "Element/QrCode"; import Copy from "Element/Copy"; -import { chunks, debounce } from "SnortUtils"; -import { useWallet } from "Wallet"; +import { debounce } from "SnortUtils"; +import { LNWallet, useWallet } from "Wallet"; import useLogin from "Hooks/useLogin"; -import { generateRandomKey } from "Login"; -import { ZapPoolController } from "ZapPoolController"; import AsyncButton from "Element/AsyncButton"; +import { ZapTarget, Zapper } from "Zapper"; import messages from "./messages"; @@ -30,12 +30,11 @@ enum ZapType { export interface SendSatsProps { onClose?: () => void; - lnurl?: string; + targets?: Array; show?: boolean; invoice?: string; // shortcut to invoice qr tab - title?: string; + title?: ReactNode; notice?: string; - target?: string; note?: HexKey; author?: HexKey; allocatePool?: boolean; @@ -43,42 +42,21 @@ export interface SendSatsProps { export default function SendSats(props: SendSatsProps) { const onClose = props.onClose || (() => undefined); - const { note, author, target } = props; - const login = useLogin(); - const defaultZapAmount = login.preferences.defaultZapAmount; - const amounts = [defaultZapAmount, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000]; - const emojis: Record = { - 1_000: "👍", - 5_000: "💜", - 10_000: "😍", - 20_000: "🤩", - 50_000: "🔥", - 100_000: "🚀", - 1_000_000: "🤯", - }; - const [handler, setHandler] = useState(); + const [zapper, setZapper] = useState(); const [invoice, setInvoice] = useState(); - const [amount, setAmount] = useState(defaultZapAmount); - const [customAmount, setCustomAmount] = useState(); - const [comment, setComment] = useState(); - const [success, setSuccess] = useState(); const [error, setError] = useState(); - const [zapType, setZapType] = useState(ZapType.PublicZap); - const [paying, setPaying] = useState(false); + const [success, setSuccess] = useState(); + const [amount, setAmount] = useState(); - const { formatMessage } = useIntl(); + const system = useContext(SnortContext); const publisher = useEventPublisher(); - const canComment = handler ? (handler.canZap && zapType !== ZapType.NonZap) || handler.maxCommentLength > 0 : false; const walletState = useWallet(); const wallet = walletState.wallet; useEffect(() => { if (props.show) { setError(undefined); - setAmount(defaultZapAmount); - setComment(undefined); - setZapType(ZapType.PublicZap); setInvoice(props.invoice); setSuccess(undefined); } @@ -94,247 +72,30 @@ export default function SendSats(props: SendSatsProps) { }, [success]); useEffect(() => { - if (props.lnurl && props.show) { + if (props.targets && props.show) { try { - const h = new LNURL(props.lnurl); - setHandler(h); - h.load().catch(e => handleLNURLError(e, formatMessage(messages.InvoiceFail))); + console.debug("loading zapper"); + const zapper = new Zapper(system, publisher); + zapper.load(props.targets).then(() => { + console.debug(zapper); + setZapper(zapper); + }); } catch (e) { + console.error(e); if (e instanceof Error) { setError(e.message); } } } - }, [props.lnurl, props.show]); - - const serviceAmounts = useMemo(() => { - if (handler) { - const min = handler.min / 1000; - const max = handler.max / 1000; - return amounts.filter(a => a >= min && a <= max); - } - return []; - }, [handler]); - const amountRows = useMemo(() => chunks(serviceAmounts, 3), [serviceAmounts]); - - const selectAmount = (a: number) => { - setError(undefined); - setAmount(a); - }; - - async function loadInvoice(): Promise { - if (!amount || !handler || !publisher) return; - - let zap: NostrEvent | undefined; - if (author && zapType !== ZapType.NonZap) { - const relays = Object.keys(login.relays.item); - - // use random key for anon zaps - if (zapType === ZapType.AnonZap) { - const randomKey = generateRandomKey(); - console.debug("Generated new key for zap: ", randomKey); - - const publisher = EventPublisher.privateKey(randomKey.privateKey); - zap = await publisher.zap(amount * 1000, author, relays, note, comment, eb => eb.tag(["anon", ""])); - } else { - zap = await publisher.zap(amount * 1000, author, relays, note, comment); - } - } - - try { - const rsp = await handler.getInvoice(amount, comment, zap); - if (rsp.pr) { - setInvoice(rsp.pr); - await payWithWallet(rsp); - } - } catch (e) { - handleLNURLError(e, formatMessage(messages.InvoiceFail)); - } - } - - function handleLNURLError(e: unknown, fallback: string) { - if (e instanceof LNURLError) { - switch (e.code) { - case LNURLErrorCode.ServiceUnavailable: { - setError(formatMessage(messages.LNURLFail)); - return; - } - case LNURLErrorCode.InvalidLNURL: { - setError(formatMessage(messages.InvalidLNURL)); - return; - } - } - } - setError(fallback); - } - - function custom() { - if (!handler) return null; - const min = handler.min / 1000; - const max = handler.max / 1000; - - return ( -
- setCustomAmount(parseInt(e.target.value))} - /> - -
- ); - } - - async function payWithWallet(invoice: LNURLInvoice) { - try { - if (wallet?.isReady()) { - setPaying(true); - const res = await wallet.payInvoice(invoice?.pr ?? ""); - if (props.allocatePool) { - ZapPoolController.allocate(amount); - } - console.log(res); - setSuccess(invoice?.successAction ?? {}); - } - } catch (e: unknown) { - console.warn(e); - if (e instanceof Error) { - setError(e.toString()); - } - } finally { - setPaying(false); - } - } - - function renderAmounts(amount: number, amounts: number[]) { - return ( -
- {amounts.map(a => ( - selectAmount(a)}> - {emojis[a] && <>{emojis[a]} } - {a === 1000 ? "1K" : formatShort(a)} - - ))} -
- ); - } - - function invoiceForm() { - if (!handler || invoice) return null; - return ( - <> -

- -

- {amountRows.map(amounts => renderAmounts(amount, amounts))} - {custom()} -
- {canComment && ( - setComment(e.target.value)} - /> - )} -
- {zapTypeSelector()} - {(amount ?? 0) > 0 && ( - loadInvoice()}> -
- - {target ? ( - - ) : ( - - )} -
-
- )} - - ); - } - - function zapTypeSelector() { - if (!handler || !handler.canZap) return; - - const makeTab = (t: ZapType, n: React.ReactNode) => ( -
setZapType(t)}> - {n} -
- ); - return ( - <> -

- -

-
- {makeTab(ZapType.PublicZap, )} - {/*makeTab(ZapType.PrivateZap, "Private")*/} - {makeTab(ZapType.AnonZap, )} - {makeTab( - ZapType.NonZap, - , - )} -
- - ); - } - - function payInvoice() { - if (success || !invoice) return null; - return ( - <> -
- {props.notice && {props.notice}} - {paying ? ( -

- - ... -

- ) : ( - - )} -
- {invoice && ( - <> -
- -
- - - )} -
-
- - ); - } + }, [props.targets, props.show]); function successAction() { if (!success) return null; return ( -
-

- - {success?.description ?? } +

+

+ + {success?.description ?? }

{success.url && (

@@ -347,29 +108,318 @@ export default function SendSats(props: SendSatsProps) { ); } - const defaultTitle = handler?.canZap ? formatMessage(messages.SendZap) : formatMessage(messages.SendSats); - const title = target - ? formatMessage(messages.ToTarget, { - action: defaultTitle, - target, - }) - : defaultTitle; + function title() { + if (!props.targets) { + return ( + <> +

+ {zapper?.canZap() ? ( + + ) : ( + + )} +

+ + ); + } + if (props.targets.length === 1 && props.targets[0].name) { + const t = props.targets[0]; + const values = { + name: t.name, + }; + return ( + <> + {t.zap?.pubkey && } +

+ {zapper?.canZap() ? ( + + ) : ( + + )} +

+ + ); + } + if (props.targets.length > 1) { + const total = props.targets.reduce((acc, v) => (acc += v.weight), 0); + + return ( +
+

+ {zapper?.canZap() ? ( + + ) : ( + + )} +

+
+ {props.targets.map(v => ( + + ))} +
+
+ ); + } + } + if (!(props.show ?? false)) return null; return ( -
e.stopPropagation()}> -
- +
+
+
{props.title || title()}
+
+ +
-
- {author && } -

{props.title || title}

-
- {invoiceForm()} + {zapper && !invoice && ( + setAmount(v)} + onNextStage={async p => { + const targetsWithComments = (props.targets ?? []).map(v => { + if (p.comment) { + v.memo = p.comment; + } + if (p.type === ZapType.AnonZap && v.zap) { + v.zap = { + ...v.zap, + anon: true, + }; + } else if (p.type === ZapType.NonZap) { + v.zap = undefined; + } + return v; + }); + if (targetsWithComments.length > 0) { + const sends = await zapper.send(wallet, targetsWithComments, p.amount); + if (sends[0].error) { + setError(sends[0].error.message); + } else if (sends.length === 1) { + setInvoice(sends[0].pr); + } else if (sends.every(a => a.sent)) { + setSuccess({}); + } + } + }} + /> + )} {error &&

{error}

} - {payInvoice()} + {invoice && !success && ( + { + setSuccess({}); + }} + /> + )} {successAction()}
); } + +interface SendSatsInputSelection { + amount: number; + comment?: string; + type: ZapType; +} + +function SendSatsInput(props: { + zapper: Zapper; + onChange?: (v: SendSatsInputSelection) => void; + onNextStage: (v: SendSatsInputSelection) => Promise; +}) { + const login = useLogin(); + const { formatMessage } = useIntl(); + const defaultZapAmount = login.preferences.defaultZapAmount; + const amounts: Record = { + [defaultZapAmount.toString()]: "", + "1000": "👍", + "5000": "💜", + "10000": "😍", + "20000": "🤩", + "50000": "🔥", + "100000": "🚀", + "1000000": "🤯", + }; + const [comment, setComment] = useState(); + const [amount, setAmount] = useState(defaultZapAmount); + const [customAmount, setCustomAmount] = useState(defaultZapAmount); + const [zapType, setZapType] = useState(ZapType.PublicZap); + + function getValue() { + return { + amount, + comment, + type: zapType, + } as SendSatsInputSelection; + } + + useEffect(() => { + if (props.onChange) { + props.onChange(getValue()); + } + }, [amount, comment, zapType]); + + function renderAmounts() { + const min = props.zapper.minAmount() / 1000; + const max = props.zapper.maxAmount() / 1000; + const filteredAmounts = Object.entries(amounts).filter(([k]) => Number(k) >= min && Number(k) <= max); + + return ( +
+ {filteredAmounts.map(([k, v]) => ( + setAmount(Number(k))}> + {v}  + {k === "1000" ? "1K" : formatShort(Number(k))} + + ))} +
+ ); + } + + function custom() { + const min = props.zapper.minAmount() / 1000; + const max = props.zapper.maxAmount() / 1000; + + return ( +
+ setCustomAmount(parseInt(e.target.value))} + /> + +
+ ); + } + + return ( +
+
+

+ +

+ {renderAmounts()} + {custom()} + {props.zapper.maxComment() > 0 && ( + setComment(e.target.value)} + /> + )} +
+ + {(amount ?? 0) > 0 && ( + props.onNextStage(getValue())}> +
+ + +
+
+ )} +
+ ); +} + +function SendSatsZapTypeSelector({ zapType, setZapType }: { zapType: ZapType; setZapType: (t: ZapType) => void }) { + const makeTab = (t: ZapType, n: React.ReactNode) => ( + + ); + return ( +
+

+ +

+
+ {makeTab(ZapType.PublicZap, )} + {/*makeTab(ZapType.PrivateZap, "Private")*/} + {makeTab(ZapType.AnonZap, )} + {makeTab( + ZapType.NonZap, + , + )} +
+
+ ); +} + +function SendSatsInvoice(props: { invoice: string; wallet?: LNWallet; notice?: ReactNode; onInvoicePaid: () => void }) { + const [paying, setPaying] = useState(false); + const [error, setError] = useState(""); + + async function payWithWallet() { + try { + if (props.wallet?.isReady()) { + setPaying(true); + const res = await props.wallet.payInvoice(props.invoice); + console.log(res); + props.onInvoicePaid(); + } + } catch (e) { + if (e instanceof Error) { + setError(e.message); + } + } finally { + setPaying(false); + } + } + + useEffect(() => { + if (props.wallet && !paying && !error) { + payWithWallet(); + } + }, [props.wallet, props.invoice, paying]); + + return ( +
+ {error &&

{error}

} + {props.notice && {props.notice}} + {paying ? ( +

+ + ... +

+ ) : ( + + )} +
+ {props.invoice && ( + <> + + + + + + )} +
+
+ ); +} diff --git a/packages/app/src/Element/Tabs.css b/packages/app/src/Element/Tabs.css index fc0ef6d4..b6aa14c2 100644 --- a/packages/app/src/Element/Tabs.css +++ b/packages/app/src/Element/Tabs.css @@ -7,7 +7,6 @@ scrollbar-width: none; /* Firefox */ white-space: nowrap; gap: 8px; - padding: 16px 12px; } .tabs::-webkit-scrollbar { diff --git a/packages/app/src/Element/Tabs.tsx b/packages/app/src/Element/Tabs.tsx index 318afefc..427610f3 100644 --- a/packages/app/src/Element/Tabs.tsx +++ b/packages/app/src/Element/Tabs.tsx @@ -31,7 +31,7 @@ export const TabElement = ({ t, tab, setTab }: TabElementProps) => { const Tabs = ({ tabs, tab, setTab }: TabsProps) => { const horizontalScroll = useHorizontalScroll(); return ( -
+
{tabs.map(t => ( ))} diff --git a/packages/app/src/Element/ZapButton.tsx b/packages/app/src/Element/ZapButton.tsx index d5ea6111..0109f828 100644 --- a/packages/app/src/Element/ZapButton.tsx +++ b/packages/app/src/Element/ZapButton.tsx @@ -29,8 +29,7 @@ const ZapButton = ({ {children}
setZap(false)} author={pubkey} diff --git a/packages/app/src/Element/messages.ts b/packages/app/src/Element/messages.ts index 2cae9eb5..904ac049 100644 --- a/packages/app/src/Element/messages.ts +++ b/packages/app/src/Element/messages.ts @@ -29,7 +29,6 @@ export default defineMessages({ PayInvoice: { defaultMessage: "Pay Invoice" }, Expired: { defaultMessage: "Expired" }, Pay: { defaultMessage: "Pay" }, - Paid: { defaultMessage: "Paid" }, Loading: { defaultMessage: "Loading..." }, Logout: { defaultMessage: "Logout" }, ShowMore: { defaultMessage: "Show more" }, @@ -64,14 +63,8 @@ export default defineMessages({ InvoiceFail: { defaultMessage: "Failed to load invoice" }, Custom: { defaultMessage: "Custom" }, Confirm: { defaultMessage: "Confirm" }, - ZapAmount: { defaultMessage: "Zap amount in sats" }, Comment: { defaultMessage: "Comment" }, - ZapTarget: { defaultMessage: "Zap {target} {n} sats" }, - ZapSats: { defaultMessage: "Zap {n} sats" }, - OpenWallet: { defaultMessage: "Open Wallet" }, SendZap: { defaultMessage: "Send zap" }, - SendSats: { defaultMessage: "Send sats" }, - ToTarget: { defaultMessage: "{action} to {target}" }, ShowReplies: { defaultMessage: "Show replies" }, TooShort: { defaultMessage: "name too short" }, TooLong: { defaultMessage: "name too long" }, diff --git a/packages/app/src/Pages/Discover.tsx b/packages/app/src/Pages/Discover.tsx index 98937236..7e99963a 100644 --- a/packages/app/src/Pages/Discover.tsx +++ b/packages/app/src/Pages/Discover.tsx @@ -29,7 +29,7 @@ export default function Discover() { return ( <> -
+
{[Tabs.Follows, Tabs.Posts, Tabs.Profiles].map(a => ( ))} diff --git a/packages/app/src/Pages/DonatePage.tsx b/packages/app/src/Pages/DonatePage.tsx index 9c1cc9c8..ca250f63 100644 --- a/packages/app/src/Pages/DonatePage.tsx +++ b/packages/app/src/Pages/DonatePage.tsx @@ -47,6 +47,8 @@ const Translators = [ bech32ToHex("npub1z9n5ktfjrlpyywds9t7ljekr9cm9jjnzs27h702te5fy8p2c4dgs5zvycf"), // Felix - DE bech32ToHex("npub1wh30wunfpkezx5s7edqu9g0s0raeetf5dgthzm0zw7sk8wqygmjqqfljgh"), // Fernando Porazzi - pt-BR + + bech32ToHex("npub1ust7u0v3qffejwhqee45r49zgcyewrcn99vdwkednd356c9resyqtnn3mj"), // Petri - FI ]; export const DonateLNURL = "donate@snort.social"; diff --git a/packages/app/src/Pages/ProfilePage.tsx b/packages/app/src/Pages/ProfilePage.tsx index b5655eec..49aa276d 100644 --- a/packages/app/src/Pages/ProfilePage.tsx +++ b/packages/app/src/Pages/ProfilePage.tsx @@ -291,11 +291,14 @@ export default function ProfilePage() { )} setShowLnQr(false)} author={id} - target={user?.display_name || user?.name} /> ); @@ -471,7 +474,7 @@ export default function ProfilePage() {
-
+
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(renderTab)} {optionalTabs.map(renderTab)} {isMe && blocked.length > 0 && renderTab(ProfileTab.Blocked)} diff --git a/packages/app/src/Pages/SearchPage.tsx b/packages/app/src/Pages/SearchPage.tsx index ead1a9c7..79d5b556 100644 --- a/packages/app/src/Pages/SearchPage.tsx +++ b/packages/app/src/Pages/SearchPage.tsx @@ -106,7 +106,7 @@ const SearchPage = () => { autoFocus={true} />
-
{[SearchTab.Posts, SearchTab.Profiles].map(renderTab)}
+
{[SearchTab.Posts, SearchTab.Profiles].map(renderTab)}
{tabContent()}
); diff --git a/packages/app/src/State/NoteCreator.ts b/packages/app/src/State/NoteCreator.ts index ccde3617..60f9a955 100644 --- a/packages/app/src/State/NoteCreator.ts +++ b/packages/app/src/State/NoteCreator.ts @@ -1,5 +1,6 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { NostrEvent, TaggedNostrEvent } from "@snort/system"; +import { ZapTarget } from "Zapper"; interface NoteCreatorStore { show: boolean; @@ -10,7 +11,7 @@ interface NoteCreatorStore { replyTo?: TaggedNostrEvent; showAdvanced: boolean; selectedCustomRelays: false | Array; - zapForward: string; + zapSplits?: Array; sensitive: string; pollOptions?: Array; otherEvents: Array; @@ -23,7 +24,6 @@ const InitState: NoteCreatorStore = { active: false, showAdvanced: false, selectedCustomRelays: false, - zapForward: "", sensitive: "", otherEvents: [], }; @@ -56,9 +56,6 @@ const NoteCreatorSlice = createSlice({ setSelectedCustomRelays: (state, action: PayloadAction>) => { state.selectedCustomRelays = action.payload; }, - setZapForward: (state, action: PayloadAction) => { - state.zapForward = action.payload; - }, setSensitive: (state, action: PayloadAction) => { state.sensitive = action.payload; }, @@ -68,6 +65,9 @@ const NoteCreatorSlice = createSlice({ setOtherEvents: (state, action: PayloadAction>) => { state.otherEvents = action.payload; }, + setZapSplits: (state, action: PayloadAction>) => { + state.zapSplits = action.payload; + }, reset: () => InitState, }, }); @@ -81,7 +81,7 @@ export const { setReplyTo, setShowAdvanced, setSelectedCustomRelays, - setZapForward, + setZapSplits, setSensitive, setPollOptions, setOtherEvents, diff --git a/packages/app/src/Zapper.ts b/packages/app/src/Zapper.ts new file mode 100644 index 00000000..a6c158e1 --- /dev/null +++ b/packages/app/src/Zapper.ts @@ -0,0 +1,208 @@ +import { LNURL } from "@snort/shared"; +import { + EventPublisher, + NostrEvent, + NostrLink, + SystemInterface, + createNostrLinkToEvent, + linkToEventTag, +} from "@snort/system"; +import { generateRandomKey } from "Login"; +import { isHex } from "SnortUtils"; +import { LNWallet, WalletInvoiceState } from "Wallet"; + +export interface ZapTarget { + type: "lnurl" | "pubkey"; + value: string; + weight: number; + memo?: string; + name?: string; + zap?: { + pubkey: string; + anon: boolean; + event?: NostrLink; + }; +} + +export interface ZapTargetResult { + target: ZapTarget; + paid: boolean; + sent: number; + fee: number; + pr: string; + error?: Error; +} + +interface ZapTargetLoaded { + target: ZapTarget; + svc?: LNURL; +} + +export class Zapper { + #inProgress = false; + #loadedTargets?: Array; + + constructor( + readonly system: SystemInterface, + readonly publisher?: EventPublisher, + readonly onResult?: (r: ZapTargetResult) => void, + ) {} + + /** + * Create targets from Event + */ + static fromEvent(ev: NostrEvent) { + return ev.tags + .filter(a => a[0] === "zap") + .map(v => { + if (v[1].length === 64 && isHex(v[1]) && v.length === 4) { + // NIP-57.G + return { + type: "pubkey", + value: v[1], + weight: Number(v[3] ?? 0), + zap: { + pubkey: v[1], + event: createNostrLinkToEvent(ev), + }, + } as ZapTarget; + } else { + // assume event specific zap target + return { + type: "lnurl", + value: v[1], + weight: 1, + zap: { + pubkey: ev.pubkey, + event: createNostrLinkToEvent(ev), + }, + } as ZapTarget; + } + }); + } + + async send(wallet: LNWallet | undefined, targets: Array, amount: number) { + if (this.#inProgress) { + throw new Error("Payout already in progress"); + } + this.#inProgress = true; + + const total = targets.reduce((acc, v) => (acc += v.weight), 0); + const ret = [] as Array; + + for (const t of targets) { + const toSend = Math.floor(amount * (t.weight / total)); + try { + const svc = await this.#getService(t); + if (!svc) { + throw new Error(`Failed to get invoice from ${t.value}`); + } + const relays = this.system.Sockets.filter(a => !a.ephemeral).map(v => v.address); + const pub = t.zap?.anon ?? false ? EventPublisher.privateKey(generateRandomKey().privateKey) : this.publisher; + const zap = + t.zap && svc.canZap + ? await pub?.zap(toSend * 1000, t.zap.pubkey, relays, undefined, t.memo, eb => { + if (t.zap?.event) { + const tag = linkToEventTag(t.zap.event); + if (tag) { + eb.tag(tag); + } + } + if (t.zap?.anon) { + eb.tag(["anon", ""]); + } + return eb; + }) + : undefined; + const invoice = await svc.getInvoice(toSend, t.memo, zap); + if (invoice?.pr) { + const res = await wallet?.payInvoice(invoice.pr); + ret.push({ + target: t, + paid: res?.state === WalletInvoiceState.Paid, + sent: toSend, + pr: invoice.pr, + fee: res?.fees ?? 0, + }); + this.onResult?.(ret[ret.length - 1]); + } else { + throw new Error(`Failed to get invoice from ${t.value}`); + } + } catch (e) { + ret.push({ + target: t, + paid: false, + sent: 0, + fee: 0, + pr: "", + error: e as Error, + }); + this.onResult?.(ret[ret.length - 1]); + } + } + + this.#inProgress = false; + return ret; + } + + async load(targets: Array) { + const svcs = targets.map(async a => { + return { + target: a, + loading: await this.#getService(a), + }; + }); + const loaded = await Promise.all(svcs); + this.#loadedTargets = loaded.map(a => ({ + target: a.target, + svc: a.loading, + })); + } + + /** + * Any target supports zaps + */ + canZap() { + return this.#loadedTargets?.some(a => a.svc?.canZap ?? false); + } + + /** + * Max comment length which can be sent to all (smallest comment length) + */ + maxComment() { + return ( + this.#loadedTargets + ?.map(a => (a.svc?.canZap ? 255 : a.svc?.maxCommentLength ?? 0)) + .reduce((acc, v) => (acc > v ? v : acc), 255) ?? 0 + ); + } + + /** + * Max of the min amounts + */ + minAmount() { + return this.#loadedTargets?.map(a => a.svc?.min ?? 0).reduce((acc, v) => (acc < v ? v : acc), 1000) ?? 0; + } + + /** + * Min of the max amounts + */ + maxAmount() { + return this.#loadedTargets?.map(a => a.svc?.max ?? 100e9).reduce((acc, v) => (acc > v ? v : acc), 100e9) ?? 0; + } + + async #getService(t: ZapTarget) { + if (t.type === "lnurl") { + const svc = new LNURL(t.value); + await svc.load(); + return svc; + } else if (t.type === "pubkey") { + const profile = await this.system.ProfileLoader.fetchProfile(t.value); + if (profile) { + const svc = new LNURL(profile.lud16 ?? profile.lud06 ?? ""); + await svc.load(); + return svc; + } + } + } +} diff --git a/packages/app/src/index.css b/packages/app/src/index.css index b9bdef1b..e759b129 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -363,6 +363,14 @@ input:disabled { flex: 2; } +.f-3 { + flex: 3; +} + +.f-4 { + flex: 4; +} + .f-grow { flex-grow: 1; min-width: 0; @@ -421,6 +429,10 @@ input:disabled { gap: 24px; } +.txt-center { + text-align: center; +} + .w-max { width: 100%; width: stretch; diff --git a/packages/app/src/lang.json b/packages/app/src/lang.json index 00790ab1..cd95fb17 100644 --- a/packages/app/src/lang.json +++ b/packages/app/src/lang.json @@ -36,6 +36,9 @@ "/RD0e2": { "defaultMessage": "Nostr uses digital signature technology to provide tamper proof notes which can safely be replicated to many relays to provide redundant storage of your content." }, + "/Xf4UW": { + "defaultMessage": "Send anonymous usage metrics" + }, "/d6vEc": { "defaultMessage": "Make your profile easier to find and share" }, @@ -157,6 +160,9 @@ "5BVs2e": { "defaultMessage": "zap" }, + "5CB6zB": { + "defaultMessage": "Zap Splits" + }, "5JcXdV": { "defaultMessage": "Create Account" }, @@ -181,6 +187,9 @@ "6Yfvvp": { "defaultMessage": "Get an identifier" }, + "6bgpn+": { + "defaultMessage": "Not all clients support this, you may still receive some zaps as if zap splits was not configured" + }, "6ewQqw": { "defaultMessage": "Likes ({n})" }, @@ -196,9 +205,6 @@ "7hp70g": { "defaultMessage": "NIP-05" }, - "7xzTiH": { - "defaultMessage": "{action} to {target}" - }, "8/vBbP": { "defaultMessage": "Reposts ({n})" }, @@ -208,6 +214,12 @@ "8QDesP": { "defaultMessage": "Zap {n} sats" }, + "8Rkoyb": { + "defaultMessage": "Recipient" + }, + "8Y6bZQ": { + "defaultMessage": "Invalid zap split: {input}" + }, "8g2vyB": { "defaultMessage": "name too long" }, @@ -217,6 +229,9 @@ "9+Ddtu": { "defaultMessage": "Next" }, + "91VPqq": { + "defaultMessage": "Paying with wallet" + }, "9HU8vw": { "defaultMessage": "Reply" }, @@ -367,9 +382,6 @@ "FDguSC": { "defaultMessage": "{n} Zaps" }, - "FP+D3H": { - "defaultMessage": "LNURL to forward zaps to" - }, "FS3b54": { "defaultMessage": "Done!" }, @@ -464,6 +476,9 @@ "JCIgkj": { "defaultMessage": "Username" }, + "JGrt9q": { + "defaultMessage": "Send sats to {name}" + }, "JHEHCk": { "defaultMessage": "Zaps ({n})" }, @@ -521,6 +536,9 @@ "Lw+I+J": { "defaultMessage": "{n,plural,=0{{name} zapped} other{{name} & {n} others zapped}}" }, + "LwYmVi": { + "defaultMessage": "Zaps on this note will be split to the following users." + }, "M3Oirc": { "defaultMessage": "Debug Menus" }, @@ -588,9 +606,6 @@ "ORGv1Q": { "defaultMessage": "Created" }, - "P04gQm": { - "defaultMessage": "All zaps sent to this note will be received by the following LNURL" - }, "P61BTu": { "defaultMessage": "Copy Event JSON" }, @@ -634,9 +649,6 @@ "R/6nsx": { "defaultMessage": "Subscription" }, - "R1fEdZ": { - "defaultMessage": "Forward Zaps" - }, "R81upa": { "defaultMessage": "People you follow" }, @@ -666,6 +678,9 @@ "defaultMessage": "Sort", "description": "Label for sorting options for people search" }, + "SMO+on": { + "defaultMessage": "Send zap to {name}" + }, "SOqbe9": { "defaultMessage": "Update Lightning Address" }, @@ -756,12 +771,18 @@ "WONP5O": { "defaultMessage": "Find your twitter follows on nostr (Data provided by {provider})" }, + "WvGmZT": { + "defaultMessage": "npub / nprofile / nostr address" + }, "WxthCV": { "defaultMessage": "e.g. Jack" }, "X7xU8J": { "defaultMessage": "nsec, npub, nip-05, hex, mnemonic" }, + "XECMfW": { + "defaultMessage": "Send usage metrics" + }, "XICsE8": { "defaultMessage": "File hosts" }, @@ -799,6 +820,9 @@ "ZLmyG9": { "defaultMessage": "Contributors" }, + "ZS+jRE": { + "defaultMessage": "Send zap splits to" + }, "ZUZedV": { "defaultMessage": "Lightning Donation:" }, @@ -1209,6 +1233,9 @@ "sWnYKw": { "defaultMessage": "Snort is designed to have a similar experience to Twitter." }, + "sZQzjQ": { + "defaultMessage": "Failed to parse zap split: {input}" + }, "svOoEH": { "defaultMessage": "Name-squatting and impersonation is not allowed. Snort and our partners reserve the right to terminate your handle (not your account - nobody can take that away) for violating this rule." }, @@ -1230,12 +1257,12 @@ "u4bHcR": { "defaultMessage": "Check out the code here: {link}" }, - "uD/N6c": { - "defaultMessage": "Zap {target} {n} sats" - }, "uSV4Ti": { "defaultMessage": "Reposts need to be manually confirmed" }, + "uc0din": { + "defaultMessage": "Send sats splits to" + }, "usAvMr": { "defaultMessage": "Edit Profile" }, @@ -1248,9 +1275,6 @@ "vOKedj": { "defaultMessage": "{n,plural,=1{& {n} other} other{& {n} others}}" }, - "vU71Ez": { - "defaultMessage": "Paying with {wallet}" - }, "vZ4quW": { "defaultMessage": "NIP-05 is a DNS based verification spec which helps to validate you as a real user." }, @@ -1336,6 +1360,9 @@ "defaultMessage": "Read global from", "description": "Label for reading global feed from specific relays" }, + "zCb8fX": { + "defaultMessage": "Weight" + }, "zFegDD": { "defaultMessage": "Contact" }, diff --git a/packages/app/src/translations/en.json b/packages/app/src/translations/en.json index e1b96dd4..9fe8f20d 100644 --- a/packages/app/src/translations/en.json +++ b/packages/app/src/translations/en.json @@ -11,6 +11,7 @@ "/JE/X+": "Account Support", "/PCavi": "Public", "/RD0e2": "Nostr uses digital signature technology to provide tamper proof notes which can safely be replicated to many relays to provide redundant storage of your content.", + "/Xf4UW": "Send anonymous usage metrics", "/d6vEc": "Make your profile easier to find and share", "/n5KSF": "{n} ms", "00LcfG": "Load more", @@ -51,6 +52,7 @@ "4Z3t5i": "Use imgproxy to compress images", "4rYCjn": "Note to Self", "5BVs2e": "zap", + "5CB6zB": "Zap Splits", "5JcXdV": "Create Account", "5oTnfy": "Buy Handle", "5rOdPG": "Once you setup your key manager extension and generated a key, you can follow our new users flow to setup your profile and help you find some interesting people on Nostr to follow.", @@ -59,15 +61,16 @@ "5ykRmX": "Send zap", "65BmHb": "Failed to proxy image from {host}, click here to load directly", "6Yfvvp": "Get an identifier", + "6bgpn+": "Not all clients support this, you may still receive some zaps as if zap splits was not configured", "6ewQqw": "Likes ({n})", "6uMqL1": "Unpaid", "7+Domh": "Notes", "7BX/yC": "Account Switcher", "7hp70g": "NIP-05", - "7xzTiH": "{action} to {target}", "8/vBbP": "Reposts ({n})", "89q5wc": "Confirm Reposts", "8QDesP": "Zap {n} sats", + "8Y6bZQ": "Invalid zap split: {input}", "8g2vyB": "name too long", "8v1NN+": "Pairing phrase", "9+Ddtu": "Next", @@ -120,7 +123,6 @@ "F+B3x1": "We have also partnered with nostrplebs.com to give you more options", "F3l7xL": "Add Account", "FDguSC": "{n} Zaps", - "FP+D3H": "LNURL to forward zaps to", "FS3b54": "Done!", "FSYL8G": "Trending Users", "FdhSU2": "Claim Now", @@ -143,6 +145,7 @@ "HOzFdo": "Muted", "HWbkEK": "Clear cache and reload", "HbefNb": "Open Wallet", + "I9zn6f": "Pubkey", "IDjHJ6": "Thanks for using Snort, please consider donating if you can.", "IEwZvs": "Are you sure you want to unpin this note?", "IKKHqV": "Follows", @@ -152,6 +155,7 @@ "Ix8l+B": "Trending Notes", "J+dIsA": "Subscriptions", "JCIgkj": "Username", + "JGrt9q": "Send sats to {name}", "JHEHCk": "Zaps ({n})", "JPFYIM": "No lightning address", "JeoS4y": "Repost", @@ -171,6 +175,7 @@ "LgbKvU": "Comment", "Lu5/Bj": "Open on Zapstr", "Lw+I+J": "{n,plural,=0{{name} zapped} other{{name} & {n} others zapped}}", + "LwYmVi": "Zaps on this note will be split to the following users.", "M3Oirc": "Debug Menus", "MBAYRO": "Shows \"Copy ID\" and \"Copy Event JSON\" in the context menu on each message", "MI2jkA": "Not available:", @@ -193,7 +198,6 @@ "OLEm6z": "Unknown login error", "OQXnew": "You subscription is still active, you can't renew yet", "ORGv1Q": "Created", - "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", @@ -208,7 +212,6 @@ "QxCuTo": "Art by {name}", "Qxv0B2": "You currently have {number} sats in your zap pool.", "R/6nsx": "Subscription", - "R1fEdZ": "Forward Zaps", "R81upa": "People you follow", "RDZVQL": "Check", "RahCRH": "Expired", @@ -218,6 +221,7 @@ "RoOyAh": "Relays", "Rs4kCE": "Bookmark", "RwFaYs": "Sort", + "SMO+on": "Send zap to {name}", "SOqbe9": "Update Lightning Address", "SP0+yi": "Buy Subscription", "SX58hM": "Copy", @@ -247,8 +251,10 @@ "W2PiAr": "{n} Blocked", "W9355R": "Unmute", "WONP5O": "Find your twitter follows on nostr (Data provided by {provider})", + "WvGmZT": "npub / nprofile / nostr address", "WxthCV": "e.g. Jack", "X7xU8J": "nsec, npub, nip-05, hex, mnemonic", + "XECMfW": "Send usage metrics", "XICsE8": "File hosts", "XgWvGA": "Reactions", "Xopqkl": "Your default zap amount is {number} sats, example values are calculated from this.", @@ -395,6 +401,7 @@ "rudscU": "Failed to load follows, please try again later", "sUNhQE": "user", "sWnYKw": "Snort is designed to have a similar experience to Twitter.", + "sZQzjQ": "Failed to parse zap split: {input}", "svOoEH": "Name-squatting and impersonation is not allowed. Snort and our partners reserve the right to terminate your handle (not your account - nobody can take that away) for violating this rule.", "tOdNiY": "Dark", "th5lxp": "Send note to a subset of your write relays", @@ -402,7 +409,6 @@ "ttxS0b": "Supporter Badge", "u/vOPu": "Paid", "u4bHcR": "Check out the code here: {link}", - "uD/N6c": "Zap {target} {n} sats", "uSV4Ti": "Reposts need to be manually confirmed", "usAvMr": "Edit Profile", "ut+2Cd": "Get a partner identifier", @@ -436,6 +442,7 @@ "y1Z3or": "Language", "yCLnBC": "LNURL or Lightning Address", "yCmnnm": "Read global from", + "zCb8fX": "Weight", "zFegDD": "Contact", "zINlao": "Owner", "zQvVDJ": "All", diff --git a/packages/shared/src/lnurl.ts b/packages/shared/src/lnurl.ts index 34b3c8fc..dbb27ee8 100644 --- a/packages/shared/src/lnurl.ts +++ b/packages/shared/src/lnurl.ts @@ -92,17 +92,6 @@ export class LNURL { 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/shared/src/utils.ts b/packages/shared/src/utils.ts index 6ecfc05c..6a8a01f3 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -43,6 +43,10 @@ export function unixNowMs() { return new Date().getTime(); } +export function jitter(n: number) { + return n * 2 * Math.random() - n; +} + export function deepClone(obj: T) { if ("structuredClone" in window) { return structuredClone(obj); diff --git a/packages/system/src/event-builder.ts b/packages/system/src/event-builder.ts index cfe88bb6..cfd587cc 100644 --- a/packages/system/src/event-builder.ts +++ b/packages/system/src/event-builder.ts @@ -1,6 +1,6 @@ import { EventKind, HexKey, NostrPrefix, NostrEvent, EventSigner, PowMiner } from "."; import { HashtagRegex, MentionNostrEntityRegex } from "./const"; -import { getPublicKey, unixNow } from "@snort/shared"; +import { getPublicKey, jitter, unixNow } from "@snort/shared"; import { EventExt } from "./event-ext"; import { tryParseNostrLink } from "./nostr-link"; @@ -12,6 +12,12 @@ export class EventBuilder { #tags: Array> = []; #pow?: number; #powMiner?: PowMiner; + #jitter?: number; + + jitter(n: number) { + this.#jitter = n; + return this; + } kind(k: EventKind) { this.#kind = k; @@ -73,8 +79,8 @@ export class EventBuilder { pubkey: this.#pubkey ?? "", content: this.#content ?? "", kind: this.#kind, - created_at: this.#createdAt ?? unixNow(), - tags: this.#tags, + created_at: (this.#createdAt ?? unixNow()) + (this.#jitter ? jitter(this.#jitter) : 0), + tags: this.#tags.sort((a, b) => a[0].localeCompare(b[0])), } as NostrEvent; ev.id = EventExt.createId(ev); return ev; diff --git a/packages/system/src/nostr-link.ts b/packages/system/src/nostr-link.ts index b566f24e..2aeedbba 100644 --- a/packages/system/src/nostr-link.ts +++ b/packages/system/src/nostr-link.ts @@ -11,6 +11,17 @@ export interface NostrLink { encode(): string; } +export function linkToEventTag(link: NostrLink) { + const relayEntry = link.relays ? [link.relays[0]] : []; + if (link.type === NostrPrefix.PublicKey) { + return ["p", link.id]; + } else if (link.type === NostrPrefix.Note || link.type === NostrPrefix.Event) { + return ["e", link.id]; + } else if (link.type === NostrPrefix.Address) { + return ["a", `${link.kind}:${link.author}:${link.id}`, ...relayEntry]; + } +} + export function createNostrLinkToEvent(ev: TaggedNostrEvent | NostrEvent) { const relays = "relays" in ev ? ev.relays : undefined; diff --git a/packages/system/src/profile-cache.ts b/packages/system/src/profile-cache.ts index 899bd73c..9a350693 100644 --- a/packages/system/src/profile-cache.ts +++ b/packages/system/src/profile-cache.ts @@ -64,6 +64,25 @@ export class ProfileLoaderService { } } + async fetchProfile(key: string) { + const existing = this.Cache.get(key); + if (existing) { + return existing; + } else { + return await new Promise((resolve, reject) => { + this.TrackMetadata(key); + const release = this.Cache.hook(() => { + const existing = this.Cache.getFromCache(key); + if (existing) { + resolve(existing); + release(); + this.UntrackMetadata(key); + } + }, key); + }); + } + } + async #FetchMetadata() { const missingFromCache = await this.#cache.buffer([...this.#wantsMetadata]);