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 ? (
-
+
) : (
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]);