Zap splits
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Kieran 2023-09-14 12:31:17 +01:00
parent 4864ef6831
commit d2baf9bd5b
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
28 changed files with 907 additions and 562 deletions

View File

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

View File

@ -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) => {
<div
onClick={onClick}
style={style}
className="avatar"
className={`avatar${imageOverlay ? " with-overlay" : ""}`}
data-domain={domain?.toLowerCase()}
title={getDisplayName(user, "")}></div>
title={getDisplayName(user, "")}>
{imageOverlay && <div className="overlay">{imageOverlay}</div>}
</div>
);
};

View File

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

View File

@ -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 (
<div className={`flex flex-row copy ${className}`} onClick={() => copy(text)}>
<span className="body">{trimmed}</span>
<div className={`copy flex pointer g8${className ? ` ${className}` : ""}`} onClick={() => copy(text)}>
<span className="copy-body">{trimmed}</span>
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
{copied ? <Icon name="check" size={14} /> : <Icon name="copy-solid" size={14} />}
</span>

View File

@ -75,7 +75,7 @@ export default function Invoice(props: InvoiceProps) {
{description && <p>{description}</p>}
{isPaid ? (
<div className="paid">
<FormattedMessage {...messages.Paid} />
<FormattedMessage defaultMessage="Paid" />
</div>
) : (
<button disabled={isExpired} type="button" onClick={payInvoice}>

View File

@ -1,9 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import "./NoteCreator.css";
import { FormattedMessage, useIntl } from "react-intl";
import { useDispatch, useSelector } from "react-redux";
import { encodeTLV, EventKind, NostrPrefix, TaggedNostrEvent, EventBuilder } from "@snort/system";
import { LNURL } from "@snort/shared";
import { encodeTLV, EventKind, NostrPrefix, TaggedNostrEvent, EventBuilder, tryParseNostrLink } from "@snort/system";
import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher";
@ -21,7 +19,7 @@ import {
setPreview,
setShowAdvanced,
setSelectedCustomRelays,
setZapForward,
setZapSplits,
setSensitive,
reset,
setPollOptions,
@ -29,16 +27,13 @@ import {
} from "State/NoteCreator";
import type { RootState } from "State/Store";
import messages from "./messages";
import { ClipboardEventHandler, useState } from "react";
import Spinner from "Icons/Spinner";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { LoginStore } from "Login";
import { getCurrentSubscription } from "Subscription";
import { ClipboardEventHandler } from "react";
import useLogin from "Hooks/useLogin";
import { System } from "index";
import AsyncButton from "Element/AsyncButton";
import { AsyncIcon } from "Element/AsyncIcon";
import { fetchNip05Pubkey } from "@snort/shared";
import { ZapTarget } from "Zapper";
export function NoteCreator() {
const { formatMessage } = useIntl();
@ -46,7 +41,7 @@ export function NoteCreator() {
const uploader = useFileUpload();
const {
note,
zapForward,
zapSplits,
sensitive,
pollOptions,
replyTo,
@ -59,45 +54,95 @@ export function NoteCreator() {
error,
} = useSelector((s: RootState) => s.noteCreator);
const dispatch = useDispatch();
const sub = getCurrentSubscription(LoginStore.allSubscriptions());
const login = useLogin();
const relays = login.relays;
async function sendNote() {
if (note && publisher) {
let extraTags: Array<Array<string>> | undefined;
if (zapForward) {
try {
const svc = new LNURL(zapForward);
await svc.load();
extraTags = [svc.getZapTag()];
} catch {
dispatch(
setError(
formatMessage({
defaultMessage: "Invalid LNURL",
}),
),
);
return;
async function buildNote() {
try {
dispatch(setError(""));
if (note && publisher) {
let extraTags: Array<Array<string>> | undefined;
if (zapSplits) {
const parsedSplits = [] as Array<ZapTarget>;
for (const s of zapSplits) {
if (s.value.startsWith(NostrPrefix.PublicKey) || s.value.startsWith(NostrPrefix.Profile)) {
const link = tryParseNostrLink(s.value);
if (link) {
parsedSplits.push({ ...s, value: link.id });
} else {
throw new Error(
formatMessage(
{
defaultMessage: "Failed to parse zap split: {input}",
},
{
input: s.value,
},
),
);
}
} else if (s.value.includes("@")) {
const [name, domain] = s.value.split("@");
const pubkey = await fetchNip05Pubkey(name, domain);
if (pubkey) {
parsedSplits.push({ ...s, value: pubkey });
} else {
throw new Error(
formatMessage(
{
defaultMessage: "Failed to parse zap split: {input}",
},
{
input: s.value,
},
),
);
}
} else {
throw new Error(
formatMessage(
{
defaultMessage: "Invalid zap split: {input}",
},
{
input: s.value,
},
),
);
}
}
extraTags = parsedSplits.map(v => ["zap", v.value, "", String(v.weight)]);
}
}
if (sensitive) {
extraTags ??= [];
extraTags.push(["content-warning", sensitive]);
if (sensitive) {
extraTags ??= [];
extraTags.push(["content-warning", sensitive]);
}
const kind = pollOptions ? EventKind.Polls : EventKind.TextNote;
if (pollOptions) {
extraTags ??= [];
extraTags.push(...pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
}
const hk = (eb: EventBuilder) => {
extraTags?.forEach(t => eb.tag(t));
eb.kind(kind);
return eb;
};
const ev = replyTo ? await publisher.reply(replyTo, note, hk) : await publisher.note(note, hk);
return ev;
}
const kind = pollOptions ? EventKind.Polls : EventKind.TextNote;
if (pollOptions) {
extraTags ??= [];
extraTags.push(...pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
} catch (e) {
if (e instanceof Error) {
dispatch(setError(e.message));
} else {
dispatch(setError(e as string));
}
const hk = (eb: EventBuilder) => {
extraTags?.forEach(t => eb.tag(t));
eb.kind(kind);
return eb;
};
const ev = replyTo ? await publisher.reply(replyTo, note, hk) : await publisher.note(note, hk);
}
}
async function sendNote() {
const ev = await buildNote();
if (ev) {
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, ev));
else System.BroadcastEvent(ev);
dispatch(reset());
@ -166,7 +211,7 @@ export function NoteCreator() {
if (preview) {
dispatch(setPreview(undefined));
} else if (publisher) {
const tmpNote = await publisher.note(note);
const tmpNote = await buildNote();
if (tmpNote) {
dispatch(setPreview(tmpNote));
}
@ -269,7 +314,7 @@ export function NoteCreator() {
);
}
function listAccounts() {
/*function listAccounts() {
return LoginStore.getSessions().map(a => (
<MenuItem
onClick={ev => {
@ -279,7 +324,7 @@ export function NoteCreator() {
<ProfileImage pubkey={a} link={""} />
</MenuItem>
));
}
}*/
const handlePaste: ClipboardEventHandler<HTMLDivElement> = evt => {
if (evt.clipboardData) {
@ -374,20 +419,63 @@ export function NoteCreator() {
</div>
<div className="flex-column g8">
<h4>
<FormattedMessage defaultMessage="Forward Zaps" />
<FormattedMessage defaultMessage="Zap Splits" />
</h4>
<FormattedMessage defaultMessage="All zaps sent to this note will be received by the following LNURL" />
<input
type="text"
className="w-max"
placeholder={formatMessage({
defaultMessage: "LNURL to forward zaps to",
})}
value={zapForward}
onChange={e => dispatch(setZapForward(e.target.value))}
/>
<FormattedMessage defaultMessage="Zaps on this note will be split to the following users." />
<div className="flex-column g8">
{[...(zapSplits ?? [])].map((v, i, arr) => (
<div className="flex f-center g8">
<div className="flex-column f-4 g4">
<h4>
<FormattedMessage defaultMessage="Recipient" />
</h4>
<input
type="text"
value={v.value}
onChange={e =>
dispatch(
setZapSplits(arr.map((vv, ii) => (ii === i ? { ...vv, value: e.target.value } : vv))),
)
}
placeholder={formatMessage({ defaultMessage: "npub / nprofile / nostr address" })}
/>
</div>
<div className="flex-column f-1 g4">
<h4>
<FormattedMessage defaultMessage="Weight" />
</h4>
<input
type="number"
min={0}
value={v.weight}
onChange={e =>
dispatch(
setZapSplits(
arr.map((vv, ii) => (ii === i ? { ...vv, weight: Number(e.target.value) } : vv)),
),
)
}
/>
</div>
<div className="flex-column f-shrink g4">
<div>&nbsp;</div>
<Icon
name="close"
onClick={() => dispatch(setZapSplits((zapSplits ?? []).filter((_v, ii) => ii !== i)))}
/>
</div>
</div>
))}
<button
type="button"
onClick={() =>
dispatch(setZapSplits([...(zapSplits ?? []), { type: "pubkey", value: "", weight: 1 }]))
}>
<FormattedMessage defaultMessage="Add" />
</button>
</div>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" />
<FormattedMessage defaultMessage="Not all clients support this, you may still receive some zaps as if zap splits was not configured" />
</span>
</div>
<div className="flex-column g8">

View File

@ -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<ZapTarget> | 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<ZapTarget>, 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 (
<AsyncFooterIcon
className={didZap ? "reacted" : ""}
@ -262,11 +268,10 @@ export default function NoteFooter(props: NoteFooterProps) {
</div>
{willRenderNoteCreator && <NoteCreator />}
<SendSats
lnurl={getLNURL()}
targets={getZapTarget()}
onClose={() => setTip(false)}
show={tip}
author={author?.pubkey}
target={getTargetName()}
note={ev.id}
allocatePool={true}
/>

View File

@ -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 (
<>
<div className="avatar-wrapper">
<Avatar pubkey={pubkey} user={user} size={size} />
<Avatar pubkey={pubkey} user={user} size={size} imageOverlay={imageOverlay} />
</div>
{showUsername && (
<div className="f-ellipsis">

View File

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

View File

@ -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<ZapTarget>;
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<number, string> = {
1_000: "👍",
5_000: "💜",
10_000: "😍",
20_000: "🤩",
50_000: "🔥",
100_000: "🚀",
1_000_000: "🤯",
};
const [handler, setHandler] = useState<LNURL>();
const [zapper, setZapper] = useState<Zapper>();
const [invoice, setInvoice] = useState<string>();
const [amount, setAmount] = useState<number>(defaultZapAmount);
const [customAmount, setCustomAmount] = useState<number>();
const [comment, setComment] = useState<string>();
const [success, setSuccess] = useState<LNURLSuccessAction>();
const [error, setError] = useState<string>();
const [zapType, setZapType] = useState(ZapType.PublicZap);
const [paying, setPaying] = useState<boolean>(false);
const [success, setSuccess] = useState<LNURLSuccessAction>();
const [amount, setAmount] = useState<SendSatsInputSelection>();
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<void> {
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 (
<div className="custom-amount flex">
<input
type="number"
min={min}
max={max}
className="f-grow mr10"
placeholder={formatMessage(messages.Custom)}
value={customAmount}
onChange={e => setCustomAmount(parseInt(e.target.value))}
/>
<button
className="secondary"
type="button"
disabled={!customAmount}
onClick={() => selectAmount(customAmount ?? 0)}>
<FormattedMessage {...messages.Confirm} />
</button>
</div>
);
}
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 (
<div className="amounts">
{amounts.map(a => (
<span className={`sat-amount ${amount === a ? "active" : ""}`} key={a} onClick={() => selectAmount(a)}>
{emojis[a] && <>{emojis[a]}&nbsp;</>}
{a === 1000 ? "1K" : formatShort(a)}
</span>
))}
</div>
);
}
function invoiceForm() {
if (!handler || invoice) return null;
return (
<>
<h3>
<FormattedMessage {...messages.ZapAmount} />
</h3>
{amountRows.map(amounts => renderAmounts(amount, amounts))}
{custom()}
<div className="flex">
{canComment && (
<input
type="text"
placeholder={formatMessage(messages.Comment)}
className="f-grow"
maxLength={handler.canZap && zapType !== ZapType.NonZap ? 250 : handler.maxCommentLength}
onChange={e => setComment(e.target.value)}
/>
)}
</div>
{zapTypeSelector()}
{(amount ?? 0) > 0 && (
<AsyncButton className="zap-action" onClick={() => loadInvoice()}>
<div className="zap-action-container">
<Icon name="zap" />
{target ? (
<FormattedMessage {...messages.ZapTarget} values={{ target, n: formatShort(amount) }} />
) : (
<FormattedMessage {...messages.ZapSats} values={{ n: formatShort(amount) }} />
)}
</div>
</AsyncButton>
)}
</>
);
}
function zapTypeSelector() {
if (!handler || !handler.canZap) return;
const makeTab = (t: ZapType, n: React.ReactNode) => (
<div className={`tab${zapType === t ? " active" : ""}`} onClick={() => setZapType(t)}>
{n}
</div>
);
return (
<>
<h3>
<FormattedMessage defaultMessage="Zap Type" />
</h3>
<div className="tabs mt10">
{makeTab(ZapType.PublicZap, <FormattedMessage defaultMessage="Public" description="Public Zap" />)}
{/*makeTab(ZapType.PrivateZap, "Private")*/}
{makeTab(ZapType.AnonZap, <FormattedMessage defaultMessage="Anon" description="Anonymous Zap" />)}
{makeTab(
ZapType.NonZap,
<FormattedMessage defaultMessage="Non-Zap" description="Non-Zap, Regular LN payment" />,
)}
</div>
</>
);
}
function payInvoice() {
if (success || !invoice) return null;
return (
<>
<div className="invoice">
{props.notice && <b className="error">{props.notice}</b>}
{paying ? (
<h4>
<FormattedMessage
defaultMessage="Paying with {wallet}"
values={{
wallet: walletState.config?.info.alias,
}}
/>
...
</h4>
) : (
<QrCode data={invoice} link={`lightning:${invoice}`} />
)}
<div className="actions">
{invoice && (
<>
<div className="copy-action">
<Copy text={invoice} maxSize={26} />
</div>
<button className="wallet-action" type="button" onClick={() => window.open(`lightning:${invoice}`)}>
<FormattedMessage {...messages.OpenWallet} />
</button>
</>
)}
</div>
</div>
</>
);
}
}, [props.targets, props.show]);
function successAction() {
if (!success) return null;
return (
<div className="success-action">
<p className="paid">
<Icon name="check" className="success mr10" />
{success?.description ?? <FormattedMessage {...messages.Paid} />}
<div className="flex f-center">
<p className="flex g12">
<Icon name="check" className="success" />
{success?.description ?? <FormattedMessage defaultMessage="Paid" />}
</p>
{success.url && (
<p>
@ -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 (
<>
<h2>
{zapper?.canZap() ? (
<FormattedMessage defaultMessage="Send zap" />
) : (
<FormattedMessage defaultMessage="Send sats" />
)}
</h2>
</>
);
}
if (props.targets.length === 1 && props.targets[0].name) {
const t = props.targets[0];
const values = {
name: t.name,
};
return (
<>
{t.zap?.pubkey && <ProfileImage pubkey={t.zap.pubkey} showUsername={false} />}
<h2>
{zapper?.canZap() ? (
<FormattedMessage defaultMessage="Send zap to {name}" values={values} />
) : (
<FormattedMessage defaultMessage="Send sats to {name}" values={values} />
)}
</h2>
</>
);
}
if (props.targets.length > 1) {
const total = props.targets.reduce((acc, v) => (acc += v.weight), 0);
return (
<div className="flex-column g12">
<h2>
{zapper?.canZap() ? (
<FormattedMessage defaultMessage="Send zap splits to" />
) : (
<FormattedMessage defaultMessage="Send sats splits to" />
)}
</h2>
<div className="flex g4">
{props.targets.map(v => (
<ProfileImage
pubkey={v.value}
showUsername={false}
imageOverlay={formatShort(Math.floor((amount?.amount ?? 0) * (v.weight / total)))}
/>
))}
</div>
</div>
);
}
}
if (!(props.show ?? false)) return null;
return (
<Modal className="lnurl-modal" onClose={onClose}>
<div className="lnurl-tip" onClick={e => e.stopPropagation()}>
<div className="close" onClick={onClose}>
<Icon name="close" />
<div className="p flex-column g12">
<div className="flex g12">
<div className="flex f-grow">{props.title || title()}</div>
<div onClick={onClose}>
<Icon name="close" />
</div>
</div>
<div className="lnurl-header">
{author && <ProfileImage pubkey={author} showUsername={false} />}
<h2>{props.title || title}</h2>
</div>
{invoiceForm()}
{zapper && !invoice && (
<SendSatsInput
zapper={zapper}
onChange={v => 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 && <p className="error">{error}</p>}
{payInvoice()}
{invoice && !success && (
<SendSatsInvoice
invoice={invoice}
wallet={wallet}
notice={props.notice}
onInvoicePaid={() => {
setSuccess({});
}}
/>
)}
{successAction()}
</div>
</Modal>
);
}
interface SendSatsInputSelection {
amount: number;
comment?: string;
type: ZapType;
}
function SendSatsInput(props: {
zapper: Zapper;
onChange?: (v: SendSatsInputSelection) => void;
onNextStage: (v: SendSatsInputSelection) => Promise<void>;
}) {
const login = useLogin();
const { formatMessage } = useIntl();
const defaultZapAmount = login.preferences.defaultZapAmount;
const amounts: Record<string, string> = {
[defaultZapAmount.toString()]: "",
"1000": "👍",
"5000": "💜",
"10000": "😍",
"20000": "🤩",
"50000": "🔥",
"100000": "🚀",
"1000000": "🤯",
};
const [comment, setComment] = useState<string>();
const [amount, setAmount] = useState<number>(defaultZapAmount);
const [customAmount, setCustomAmount] = useState<number>(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 (
<div className="amounts">
{filteredAmounts.map(([k, v]) => (
<span
className={`sat-amount ${amount === Number(k) ? "active" : ""}`}
key={k}
onClick={() => setAmount(Number(k))}>
{v}&nbsp;
{k === "1000" ? "1K" : formatShort(Number(k))}
</span>
))}
</div>
);
}
function custom() {
const min = props.zapper.minAmount() / 1000;
const max = props.zapper.maxAmount() / 1000;
return (
<div className="flex g8">
<input
type="number"
min={min}
max={max}
className="f-grow"
placeholder={formatMessage(messages.Custom)}
value={customAmount}
onChange={e => setCustomAmount(parseInt(e.target.value))}
/>
<button
className="secondary"
type="button"
disabled={!customAmount}
onClick={() => setAmount(customAmount ?? 0)}>
<FormattedMessage {...messages.Confirm} />
</button>
</div>
);
}
return (
<div className="flex-column g24">
<div className="flex-column g8">
<h3>
<FormattedMessage defaultMessage="Zap amount in sats" />
</h3>
{renderAmounts()}
{custom()}
{props.zapper.maxComment() > 0 && (
<input
type="text"
placeholder={formatMessage(messages.Comment)}
className="f-grow"
maxLength={props.zapper.maxComment()}
onChange={e => setComment(e.target.value)}
/>
)}
</div>
<SendSatsZapTypeSelector zapType={zapType} setZapType={setZapType} />
{(amount ?? 0) > 0 && (
<AsyncButton className="zap-action" onClick={() => props.onNextStage(getValue())}>
<div className="zap-action-container">
<Icon name="zap" />
<FormattedMessage defaultMessage="Zap {n} sats" values={{ n: formatShort(amount) }} />
</div>
</AsyncButton>
)}
</div>
);
}
function SendSatsZapTypeSelector({ zapType, setZapType }: { zapType: ZapType; setZapType: (t: ZapType) => void }) {
const makeTab = (t: ZapType, n: React.ReactNode) => (
<button type="button" className={zapType === t ? "" : "secondary"} onClick={() => setZapType(t)}>
{n}
</button>
);
return (
<div className="flex-column g8">
<h3>
<FormattedMessage defaultMessage="Zap Type" />
</h3>
<div className="flex g8">
{makeTab(ZapType.PublicZap, <FormattedMessage defaultMessage="Public" description="Public Zap" />)}
{/*makeTab(ZapType.PrivateZap, "Private")*/}
{makeTab(ZapType.AnonZap, <FormattedMessage defaultMessage="Anon" description="Anonymous Zap" />)}
{makeTab(
ZapType.NonZap,
<FormattedMessage defaultMessage="Non-Zap" description="Non-Zap, Regular LN payment" />,
)}
</div>
</div>
);
}
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 (
<div className="flex-column g12 txt-center">
{error && <p className="error">{error}</p>}
{props.notice && <b className="error">{props.notice}</b>}
{paying ? (
<h4>
<FormattedMessage defaultMessage="Paying with wallet" />
...
</h4>
) : (
<QrCode data={props.invoice} link={`lightning:${props.invoice}`} />
)}
<div className="flex-column g12">
{props.invoice && (
<>
<Copy text={props.invoice} maxSize={26} className="f-center" />
<a href={`lightning:${props.invoice}`}>
<button type="button">
<FormattedMessage defaultMessage="Open Wallet" />
</button>
</a>
</>
)}
</div>
</div>
);
}

View File

@ -7,7 +7,6 @@
scrollbar-width: none; /* Firefox */
white-space: nowrap;
gap: 8px;
padding: 16px 12px;
}
.tabs::-webkit-scrollbar {

View File

@ -31,7 +31,7 @@ export const TabElement = ({ t, tab, setTab }: TabElementProps) => {
const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
const horizontalScroll = useHorizontalScroll();
return (
<div className="tabs" ref={horizontalScroll}>
<div className="tabs p" ref={horizontalScroll}>
{tabs.map(t => (
<TabElement tab={tab} setTab={setTab} t={t} />
))}

View File

@ -29,8 +29,7 @@ const ZapButton = ({
{children}
</div>
<SendSats
target={profile?.display_name || profile?.name}
lnurl={service}
targets={[{ type: "lnurl", value: service, weight: 1, name: profile?.display_name || profile?.name }]}
show={zap}
onClose={() => setZap(false)}
author={pubkey}

View File

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

View File

@ -29,7 +29,7 @@ export default function Discover() {
return (
<>
<div className="tabs">
<div className="tabs p">
{[Tabs.Follows, Tabs.Posts, Tabs.Profiles].map(a => (
<TabElement key={a.value} tab={tab} setTab={setTab} t={a} />
))}

View File

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

View File

@ -291,11 +291,14 @@ export default function ProfilePage() {
)}
<SendSats
lnurl={lnurl?.lnurl}
targets={
lnurl?.lnurl
? [{ type: "lnurl", value: lnurl?.lnurl, weight: 1, name: user?.display_name || user?.name }]
: undefined
}
show={showLnQr}
onClose={() => setShowLnQr(false)}
author={id}
target={user?.display_name || user?.name}
/>
</>
);
@ -471,7 +474,7 @@ export default function ProfilePage() {
</div>
</div>
<div className="main-content">
<div className="tabs" ref={horizontalScroll}>
<div className="tabs p" ref={horizontalScroll}>
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(renderTab)}
{optionalTabs.map(renderTab)}
{isMe && blocked.length > 0 && renderTab(ProfileTab.Blocked)}

View File

@ -106,7 +106,7 @@ const SearchPage = () => {
autoFocus={true}
/>
</div>
<div className="tabs">{[SearchTab.Posts, SearchTab.Profiles].map(renderTab)}</div>
<div className="tabs p">{[SearchTab.Posts, SearchTab.Profiles].map(renderTab)}</div>
{tabContent()}
</div>
);

View File

@ -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<string>;
zapForward: string;
zapSplits?: Array<ZapTarget>;
sensitive: string;
pollOptions?: Array<string>;
otherEvents: Array<NostrEvent>;
@ -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<false | Array<string>>) => {
state.selectedCustomRelays = action.payload;
},
setZapForward: (state, action: PayloadAction<string>) => {
state.zapForward = action.payload;
},
setSensitive: (state, action: PayloadAction<string>) => {
state.sensitive = action.payload;
},
@ -68,6 +65,9 @@ const NoteCreatorSlice = createSlice({
setOtherEvents: (state, action: PayloadAction<Array<NostrEvent>>) => {
state.otherEvents = action.payload;
},
setZapSplits: (state, action: PayloadAction<Array<ZapTarget>>) => {
state.zapSplits = action.payload;
},
reset: () => InitState,
},
});
@ -81,7 +81,7 @@ export const {
setReplyTo,
setShowAdvanced,
setSelectedCustomRelays,
setZapForward,
setZapSplits,
setSensitive,
setPollOptions,
setOtherEvents,

208
packages/app/src/Zapper.ts Normal file
View File

@ -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<ZapTargetLoaded>;
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<ZapTarget>, 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<ZapTargetResult>;
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<ZapTarget>) {
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;
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<T>(obj: T) {
if ("structuredClone" in window) {
return structuredClone(obj);

View File

@ -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<Array<string>> = [];
#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;

View File

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

View File

@ -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<MetadataCache>((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]);