Zap splits
Some checks failed
continuous-integration/drone/push Build is failing

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"] { .avatar[data-domain="strike.army"] {
background-image: var(--img-url), var(--strike-army-gradient); 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 "./Avatar.css";
import { CSSProperties, useEffect, useState } from "react"; import { CSSProperties, ReactNode, useEffect, useState } from "react";
import type { UserMetadata } from "@snort/system"; import type { UserMetadata } from "@snort/system";
import useImgProxy from "Hooks/useImgProxy"; import useImgProxy from "Hooks/useImgProxy";
@ -13,8 +13,9 @@ interface AvatarProps {
onClick?: () => void; onClick?: () => void;
size?: number; size?: number;
image?: string; 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 [url, setUrl] = useState("");
const { proxy } = useImgProxy(); const { proxy } = useImgProxy();
@ -35,9 +36,11 @@ const Avatar = ({ pubkey, user, size, onClick, image }: AvatarProps) => {
<div <div
onClick={onClick} onClick={onClick}
style={style} style={style}
className="avatar" className={`avatar${imageOverlay ? " with-overlay" : ""}`}
data-domain={domain?.toLowerCase()} 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 { .copy .copy-body {
cursor: pointer;
align-items: center;
}
.copy .body {
font-size: var(--font-size-small); font-size: var(--font-size-small);
color: var(--font-color); color: var(--font-color);
margin-right: 6px; 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; const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text;
return ( return (
<div className={`flex flex-row copy ${className}`} onClick={() => copy(text)}> <div className={`copy flex pointer g8${className ? ` ${className}` : ""}`} onClick={() => copy(text)}>
<span className="body">{trimmed}</span> <span className="copy-body">{trimmed}</span>
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}> <span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
{copied ? <Icon name="check" size={14} /> : <Icon name="copy-solid" size={14} />} {copied ? <Icon name="check" size={14} /> : <Icon name="copy-solid" size={14} />}
</span> </span>

View File

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

View File

@ -1,9 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import "./NoteCreator.css"; import "./NoteCreator.css";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { encodeTLV, EventKind, NostrPrefix, TaggedNostrEvent, EventBuilder } from "@snort/system"; import { encodeTLV, EventKind, NostrPrefix, TaggedNostrEvent, EventBuilder, tryParseNostrLink } from "@snort/system";
import { LNURL } from "@snort/shared";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
@ -21,7 +19,7 @@ import {
setPreview, setPreview,
setShowAdvanced, setShowAdvanced,
setSelectedCustomRelays, setSelectedCustomRelays,
setZapForward, setZapSplits,
setSensitive, setSensitive,
reset, reset,
setPollOptions, setPollOptions,
@ -29,16 +27,13 @@ import {
} from "State/NoteCreator"; } from "State/NoteCreator";
import type { RootState } from "State/Store"; import type { RootState } from "State/Store";
import messages from "./messages"; import { ClipboardEventHandler } from "react";
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 useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { System } from "index"; import { System } from "index";
import AsyncButton from "Element/AsyncButton"; import AsyncButton from "Element/AsyncButton";
import { AsyncIcon } from "Element/AsyncIcon"; import { AsyncIcon } from "Element/AsyncIcon";
import { fetchNip05Pubkey } from "@snort/shared";
import { ZapTarget } from "Zapper";
export function NoteCreator() { export function NoteCreator() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
@ -46,7 +41,7 @@ export function NoteCreator() {
const uploader = useFileUpload(); const uploader = useFileUpload();
const { const {
note, note,
zapForward, zapSplits,
sensitive, sensitive,
pollOptions, pollOptions,
replyTo, replyTo,
@ -59,45 +54,95 @@ export function NoteCreator() {
error, error,
} = useSelector((s: RootState) => s.noteCreator); } = useSelector((s: RootState) => s.noteCreator);
const dispatch = useDispatch(); const dispatch = useDispatch();
const sub = getCurrentSubscription(LoginStore.allSubscriptions());
const login = useLogin(); const login = useLogin();
const relays = login.relays; const relays = login.relays;
async function sendNote() { async function buildNote() {
if (note && publisher) { try {
let extraTags: Array<Array<string>> | undefined; dispatch(setError(""));
if (zapForward) { if (note && publisher) {
try { let extraTags: Array<Array<string>> | undefined;
const svc = new LNURL(zapForward); if (zapSplits) {
await svc.load(); const parsedSplits = [] as Array<ZapTarget>;
extraTags = [svc.getZapTag()]; for (const s of zapSplits) {
} catch { if (s.value.startsWith(NostrPrefix.PublicKey) || s.value.startsWith(NostrPrefix.Profile)) {
dispatch( const link = tryParseNostrLink(s.value);
setError( if (link) {
formatMessage({ parsedSplits.push({ ...s, value: link.id });
defaultMessage: "Invalid LNURL", } else {
}), throw new Error(
), formatMessage(
); {
return; 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) { if (sensitive) {
extraTags ??= []; extraTags ??= [];
extraTags.push(["content-warning", sensitive]); 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; } catch (e) {
if (pollOptions) { if (e instanceof Error) {
extraTags ??= []; dispatch(setError(e.message));
extraTags.push(...pollOptions.map((a, i) => ["poll_option", i.toString(), a])); } else {
dispatch(setError(e as string));
} }
const hk = (eb: EventBuilder) => { }
extraTags?.forEach(t => eb.tag(t)); }
eb.kind(kind);
return eb; async function sendNote() {
}; const ev = await buildNote();
const ev = replyTo ? await publisher.reply(replyTo, note, hk) : await publisher.note(note, hk); if (ev) {
if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, ev)); if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, ev));
else System.BroadcastEvent(ev); else System.BroadcastEvent(ev);
dispatch(reset()); dispatch(reset());
@ -166,7 +211,7 @@ export function NoteCreator() {
if (preview) { if (preview) {
dispatch(setPreview(undefined)); dispatch(setPreview(undefined));
} else if (publisher) { } else if (publisher) {
const tmpNote = await publisher.note(note); const tmpNote = await buildNote();
if (tmpNote) { if (tmpNote) {
dispatch(setPreview(tmpNote)); dispatch(setPreview(tmpNote));
} }
@ -269,7 +314,7 @@ export function NoteCreator() {
); );
} }
function listAccounts() { /*function listAccounts() {
return LoginStore.getSessions().map(a => ( return LoginStore.getSessions().map(a => (
<MenuItem <MenuItem
onClick={ev => { onClick={ev => {
@ -279,7 +324,7 @@ export function NoteCreator() {
<ProfileImage pubkey={a} link={""} /> <ProfileImage pubkey={a} link={""} />
</MenuItem> </MenuItem>
)); ));
} }*/
const handlePaste: ClipboardEventHandler<HTMLDivElement> = evt => { const handlePaste: ClipboardEventHandler<HTMLDivElement> = evt => {
if (evt.clipboardData) { if (evt.clipboardData) {
@ -374,20 +419,63 @@ export function NoteCreator() {
</div> </div>
<div className="flex-column g8"> <div className="flex-column g8">
<h4> <h4>
<FormattedMessage defaultMessage="Forward Zaps" /> <FormattedMessage defaultMessage="Zap Splits" />
</h4> </h4>
<FormattedMessage defaultMessage="All zaps sent to this note will be received by the following LNURL" /> <FormattedMessage defaultMessage="Zaps on this note will be split to the following users." />
<input <div className="flex-column g8">
type="text" {[...(zapSplits ?? [])].map((v, i, arr) => (
className="w-max" <div className="flex f-center g8">
placeholder={formatMessage({ <div className="flex-column f-4 g4">
defaultMessage: "LNURL to forward zaps to", <h4>
})} <FormattedMessage defaultMessage="Recipient" />
value={zapForward} </h4>
onChange={e => dispatch(setZapForward(e.target.value))} <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"> <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> </span>
</div> </div>
<div className="flex-column g8"> <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 { useSelector, useDispatch } from "react-redux";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { useLongPress } from "use-long-press"; import { useLongPress } from "use-long-press";
import { TaggedNostrEvent, HexKey, u256, ParsedZap, countLeadingZeros } from "@snort/system"; import { TaggedNostrEvent, ParsedZap, countLeadingZeros, createNostrLinkToEvent } from "@snort/system";
import { LNURL } from "@snort/shared"; import { SnortContext, useUserProfile } from "@snort/system-react";
import { useUserProfile } from "@snort/system-react";
import { formatShort } from "Number"; import { formatShort } from "Number";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import { delay, findTag, normalizeReaction, unwrap } from "SnortUtils"; import { delay, findTag, normalizeReaction } from "SnortUtils";
import { NoteCreator } from "Element/NoteCreator"; import { NoteCreator } from "Element/NoteCreator";
import SendSats from "Element/SendSats"; import SendSats from "Element/SendSats";
import { ZapsSummary } from "Element/Zap"; import { ZapsSummary } from "Element/Zap";
@ -21,6 +20,8 @@ import useLogin from "Hooks/useLogin";
import { useInteractionCache } from "Hooks/useInteractionCache"; import { useInteractionCache } from "Hooks/useInteractionCache";
import { ZapPoolController } from "ZapPoolController"; import { ZapPoolController } from "ZapPoolController";
import { System } from "index"; import { System } from "index";
import { Zapper, ZapTarget } from "Zapper";
import { getDisplayName } from "./ProfileImage";
import messages from "./messages"; import messages from "./messages";
@ -47,9 +48,10 @@ export interface NoteFooterProps {
export default function NoteFooter(props: NoteFooterProps) { export default function NoteFooter(props: NoteFooterProps) {
const { ev, positive, reposts, zaps } = props; const { ev, positive, reposts, zaps } = props;
const dispatch = useDispatch(); const dispatch = useDispatch();
const system = useContext(SnortContext);
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const login = useLogin(); const login = useLogin();
const { publicKey, preferences: prefs, relays } = login; const { publicKey, preferences: prefs } = login;
const author = useUserProfile(ev.pubkey); const author = useUserProfile(ev.pubkey);
const interactionCache = useInteractionCache(publicKey, ev.id); const interactionCache = useInteractionCache(publicKey, ev.id);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
@ -103,31 +105,36 @@ export default function NoteFooter(props: NoteFooterProps) {
} }
} }
function getLNURL() { function getZapTarget(): Array<ZapTarget> | undefined {
return ev.tags.find(a => a[0] === "zap")?.[1] || author?.lud16 || author?.lud06; if (ev.tags.some(v => v[0] === "zap")) {
} return Zapper.fromEvent(ev);
}
function getTargetName() { const authorTarget = author?.lud16 || author?.lud06;
const zapTarget = ev.tags.find(a => a[0] === "zap")?.[1]; if (authorTarget) {
if (zapTarget) { return [
try { {
return new LNURL(zapTarget).name; type: "lnurl",
} catch { value: authorTarget,
// ignore weight: 1,
} name: getDisplayName(author, ev.pubkey),
} else { zap: {
return author?.display_name || author?.name; pubkey: ev.pubkey,
event: createNostrLinkToEvent(ev),
},
} as ZapTarget,
];
} }
} }
async function fastZap(e?: React.MouseEvent) { async function fastZap(e?: React.MouseEvent) {
if (zapping || e?.isPropagationStopped()) return; if (zapping || e?.isPropagationStopped()) return;
const lnurl = getLNURL(); const lnurl = getZapTarget();
if (wallet?.isReady() && lnurl) { if (wallet?.isReady() && lnurl) {
setZapping(true); setZapping(true);
try { try {
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id); await fastZapInner(lnurl, prefs.defaultZapAmount);
} catch (e) { } catch (e) {
console.warn("Fast zap failed", e); console.warn("Fast zap failed", e);
if (!(e instanceof Error) || e.message !== "User rejected") { 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) { async function fastZapInner(targets: Array<ZapTarget>, amount: number) {
// only allow 1 invoice req/payment at a time to avoid hitting rate limits if (wallet) {
await barrierZapper(async () => { // only allow 1 invoice req/payment at a time to avoid hitting rate limits
const handler = new LNURL(lnurl); await barrierZapper(async () => {
await handler.load(); const zapper = new Zapper(system, publisher);
const result = await zapper.send(wallet, targets, amount);
const zr = Object.keys(relays.item); const totalSent = result.reduce((acc, v) => (acc += v.sent), 0);
const zap = handler.canZap && publisher ? await publisher.zap(amount * 1000, key, zr, id) : undefined; if (totalSent > 0) {
const invoice = await handler.getInvoice(amount, undefined, zap); ZapPoolController.allocate(totalSent);
await wallet?.payInvoice(unwrap(invoice.pr)); await interactionCache.zap();
ZapPoolController.allocate(amount); }
});
await interactionCache.zap(); }
});
} }
useEffect(() => { useEffect(() => {
if (prefs.autoZap && !didZap && !isMine && !zapping) { if (prefs.autoZap && !didZap && !isMine && !zapping) {
const lnurl = getLNURL(); const lnurl = getZapTarget();
if (wallet?.isReady() && lnurl) { if (wallet?.isReady() && lnurl) {
setZapping(true); setZapping(true);
queueMicrotask(async () => { queueMicrotask(async () => {
try { try {
await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id); await fastZapInner(lnurl, prefs.defaultZapAmount);
} catch { } catch {
// ignored // ignored
} finally { } finally {
@ -185,8 +191,8 @@ export default function NoteFooter(props: NoteFooterProps) {
} }
function tipButton() { function tipButton() {
const service = getLNURL(); const targets = getZapTarget();
if (service) { if (targets) {
return ( return (
<AsyncFooterIcon <AsyncFooterIcon
className={didZap ? "reacted" : ""} className={didZap ? "reacted" : ""}
@ -262,11 +268,10 @@ export default function NoteFooter(props: NoteFooterProps) {
</div> </div>
{willRenderNoteCreator && <NoteCreator />} {willRenderNoteCreator && <NoteCreator />}
<SendSats <SendSats
lnurl={getLNURL()} targets={getZapTarget()}
onClose={() => setTip(false)} onClose={() => setTip(false)}
show={tip} show={tip}
author={author?.pubkey} author={author?.pubkey}
target={getTargetName()}
note={ev.id} note={ev.id}
allocatePool={true} allocatePool={true}
/> />

View File

@ -1,6 +1,6 @@
import "./ProfileImage.css"; import "./ProfileImage.css";
import React, { useMemo } from "react"; import React, { ReactNode, useMemo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { HexKey, NostrPrefix, UserMetadata } from "@snort/system"; import { HexKey, NostrPrefix, UserMetadata } from "@snort/system";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
@ -21,6 +21,7 @@ export interface ProfileImageProps {
profile?: UserMetadata; profile?: UserMetadata;
size?: number; size?: number;
onClick?: (e: React.MouseEvent) => void; onClick?: (e: React.MouseEvent) => void;
imageOverlay?: ReactNode;
} }
export default function ProfileImage({ export default function ProfileImage({
@ -34,6 +35,7 @@ export default function ProfileImage({
overrideUsername, overrideUsername,
profile, profile,
size, size,
imageOverlay,
onClick, onClick,
}: ProfileImageProps) { }: ProfileImageProps) {
const user = useUserProfile(profile ? "" : pubkey) ?? profile; const user = useUserProfile(profile ? "" : pubkey) ?? profile;
@ -54,7 +56,7 @@ export default function ProfileImage({
return ( return (
<> <>
<div className="avatar-wrapper"> <div className="avatar-wrapper">
<Avatar pubkey={pubkey} user={user} size={size} /> <Avatar pubkey={pubkey} user={user} size={size} imageOverlay={imageOverlay} />
</div> </div>
{showUsername && ( {showUsername && (
<div className="f-ellipsis"> <div className="f-ellipsis">

View File

@ -1,31 +1,32 @@
.lnurl-modal .modal-body { .lnurl-modal .modal-body {
padding: 0; padding: 12px 24px;
max-width: 470px; max-width: 500px;
} }
.lnurl-modal .lnurl-tip .pfp .avatar { .lnurl-modal .pfp .avatar {
width: 48px; width: 48px;
height: 48px; height: 48px;
} }
.lnurl-tip {
padding: 24px 32px;
background-color: #1b1b1b;
border-radius: 16px;
position: relative;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.lnurl-tip { .lnurl-modal {
padding: 12px 16px; padding: 12px 16px;
} }
} }
.light .lnurl-tip { .light .lnurl-modal {
background-color: var(--gray-superdark); 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); color: var(--font-secondary-color);
font-size: 11px; font-size: 11px;
letter-spacing: 0.11em; letter-spacing: 0.11em;
@ -34,43 +35,14 @@
text-transform: uppercase; 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 { .amounts {
display: flex; display: grid;
width: 100%; grid-template-columns: repeat(4, 1fr);
margin-bottom: 16px; gap: 12px;
} }
.sat-amount { .sat-amount {
flex: 1 1 auto;
text-align: center; text-align: center;
display: inline-block;
background-color: #2a2a2a; background-color: #2a2a2a;
color: var(--font-color); color: var(--font-color);
padding: 12px 16px; padding: 12px 16px;
@ -79,83 +51,28 @@
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
line-height: 17px; line-height: 17px;
cursor: pointer;
} }
.light .sat-amount { .light .sat-amount {
background-color: var(--gray); background-color: var(--gray);
} }
.sat-amount:not(:last-child) {
margin-right: 8px;
}
.sat-amount:hover {
cursor: pointer;
}
.sat-amount.active { .sat-amount.active {
font-weight: bold; font-weight: bold;
color: var(--gray-superdark); color: var(--gray-superdark);
background-color: var(--font-color); background-color: var(--font-color);
} }
.lnurl-tip .invoice { .lnurl-modal canvas {
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 {
border-radius: 10px; border-radius: 10px;
} }
.lnurl-tip .success-action .paid { .lnurl-modal .success-action .paid {
font-size: 19px; font-size: 19px;
} }
.lnurl-tip .success-action a { .lnurl-modal .success-action a {
color: var(--highlight); color: var(--highlight);
font-size: 19px; font-size: 19px;
} }

View File

@ -1,9 +1,10 @@
import "./SendSats.css"; 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 { useIntl, FormattedMessage } from "react-intl";
import { HexKey, NostrEvent, EventPublisher } from "@snort/system"; import { HexKey } from "@snort/system";
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "@snort/shared"; import { SnortContext } from "@snort/system-react";
import { LNURLSuccessAction } from "@snort/shared";
import { formatShort } from "Number"; import { formatShort } from "Number";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
@ -12,12 +13,11 @@ import ProfileImage from "Element/ProfileImage";
import Modal from "Element/Modal"; import Modal from "Element/Modal";
import QrCode from "Element/QrCode"; import QrCode from "Element/QrCode";
import Copy from "Element/Copy"; import Copy from "Element/Copy";
import { chunks, debounce } from "SnortUtils"; import { debounce } from "SnortUtils";
import { useWallet } from "Wallet"; import { LNWallet, useWallet } from "Wallet";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { generateRandomKey } from "Login";
import { ZapPoolController } from "ZapPoolController";
import AsyncButton from "Element/AsyncButton"; import AsyncButton from "Element/AsyncButton";
import { ZapTarget, Zapper } from "Zapper";
import messages from "./messages"; import messages from "./messages";
@ -30,12 +30,11 @@ enum ZapType {
export interface SendSatsProps { export interface SendSatsProps {
onClose?: () => void; onClose?: () => void;
lnurl?: string; targets?: Array<ZapTarget>;
show?: boolean; show?: boolean;
invoice?: string; // shortcut to invoice qr tab invoice?: string; // shortcut to invoice qr tab
title?: string; title?: ReactNode;
notice?: string; notice?: string;
target?: string;
note?: HexKey; note?: HexKey;
author?: HexKey; author?: HexKey;
allocatePool?: boolean; allocatePool?: boolean;
@ -43,42 +42,21 @@ export interface SendSatsProps {
export default function SendSats(props: SendSatsProps) { export default function SendSats(props: SendSatsProps) {
const onClose = props.onClose || (() => undefined); 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 [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 [error, setError] = useState<string>();
const [zapType, setZapType] = useState(ZapType.PublicZap); const [success, setSuccess] = useState<LNURLSuccessAction>();
const [paying, setPaying] = useState<boolean>(false); const [amount, setAmount] = useState<SendSatsInputSelection>();
const { formatMessage } = useIntl(); const system = useContext(SnortContext);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const canComment = handler ? (handler.canZap && zapType !== ZapType.NonZap) || handler.maxCommentLength > 0 : false;
const walletState = useWallet(); const walletState = useWallet();
const wallet = walletState.wallet; const wallet = walletState.wallet;
useEffect(() => { useEffect(() => {
if (props.show) { if (props.show) {
setError(undefined); setError(undefined);
setAmount(defaultZapAmount);
setComment(undefined);
setZapType(ZapType.PublicZap);
setInvoice(props.invoice); setInvoice(props.invoice);
setSuccess(undefined); setSuccess(undefined);
} }
@ -94,247 +72,30 @@ export default function SendSats(props: SendSatsProps) {
}, [success]); }, [success]);
useEffect(() => { useEffect(() => {
if (props.lnurl && props.show) { if (props.targets && props.show) {
try { try {
const h = new LNURL(props.lnurl); console.debug("loading zapper");
setHandler(h); const zapper = new Zapper(system, publisher);
h.load().catch(e => handleLNURLError(e, formatMessage(messages.InvoiceFail))); zapper.load(props.targets).then(() => {
console.debug(zapper);
setZapper(zapper);
});
} catch (e) { } catch (e) {
console.error(e);
if (e instanceof Error) { if (e instanceof Error) {
setError(e.message); setError(e.message);
} }
} }
} }
}, [props.lnurl, props.show]); }, [props.targets, 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>
</>
);
}
function successAction() { function successAction() {
if (!success) return null; if (!success) return null;
return ( return (
<div className="success-action"> <div className="flex f-center">
<p className="paid"> <p className="flex g12">
<Icon name="check" className="success mr10" /> <Icon name="check" className="success" />
{success?.description ?? <FormattedMessage {...messages.Paid} />} {success?.description ?? <FormattedMessage defaultMessage="Paid" />}
</p> </p>
{success.url && ( {success.url && (
<p> <p>
@ -347,29 +108,318 @@ export default function SendSats(props: SendSatsProps) {
); );
} }
const defaultTitle = handler?.canZap ? formatMessage(messages.SendZap) : formatMessage(messages.SendSats); function title() {
const title = target if (!props.targets) {
? formatMessage(messages.ToTarget, { return (
action: defaultTitle, <>
target, <h2>
}) {zapper?.canZap() ? (
: defaultTitle; <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; if (!(props.show ?? false)) return null;
return ( return (
<Modal className="lnurl-modal" onClose={onClose}> <Modal className="lnurl-modal" onClose={onClose}>
<div className="lnurl-tip" onClick={e => e.stopPropagation()}> <div className="p flex-column g12">
<div className="close" onClick={onClose}> <div className="flex g12">
<Icon name="close" /> <div className="flex f-grow">{props.title || title()}</div>
<div onClick={onClose}>
<Icon name="close" />
</div>
</div> </div>
<div className="lnurl-header"> {zapper && !invoice && (
{author && <ProfileImage pubkey={author} showUsername={false} />} <SendSatsInput
<h2>{props.title || title}</h2> zapper={zapper}
</div> onChange={v => setAmount(v)}
{invoiceForm()} 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>} {error && <p className="error">{error}</p>}
{payInvoice()} {invoice && !success && (
<SendSatsInvoice
invoice={invoice}
wallet={wallet}
notice={props.notice}
onInvoicePaid={() => {
setSuccess({});
}}
/>
)}
{successAction()} {successAction()}
</div> </div>
</Modal> </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 */ scrollbar-width: none; /* Firefox */
white-space: nowrap; white-space: nowrap;
gap: 8px; gap: 8px;
padding: 16px 12px;
} }
.tabs::-webkit-scrollbar { .tabs::-webkit-scrollbar {

View File

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

View File

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

View File

@ -29,7 +29,6 @@ export default defineMessages({
PayInvoice: { defaultMessage: "Pay Invoice" }, PayInvoice: { defaultMessage: "Pay Invoice" },
Expired: { defaultMessage: "Expired" }, Expired: { defaultMessage: "Expired" },
Pay: { defaultMessage: "Pay" }, Pay: { defaultMessage: "Pay" },
Paid: { defaultMessage: "Paid" },
Loading: { defaultMessage: "Loading..." }, Loading: { defaultMessage: "Loading..." },
Logout: { defaultMessage: "Logout" }, Logout: { defaultMessage: "Logout" },
ShowMore: { defaultMessage: "Show more" }, ShowMore: { defaultMessage: "Show more" },
@ -64,14 +63,8 @@ export default defineMessages({
InvoiceFail: { defaultMessage: "Failed to load invoice" }, InvoiceFail: { defaultMessage: "Failed to load invoice" },
Custom: { defaultMessage: "Custom" }, Custom: { defaultMessage: "Custom" },
Confirm: { defaultMessage: "Confirm" }, Confirm: { defaultMessage: "Confirm" },
ZapAmount: { defaultMessage: "Zap amount in sats" },
Comment: { defaultMessage: "Comment" }, Comment: { defaultMessage: "Comment" },
ZapTarget: { defaultMessage: "Zap {target} {n} sats" },
ZapSats: { defaultMessage: "Zap {n} sats" },
OpenWallet: { defaultMessage: "Open Wallet" },
SendZap: { defaultMessage: "Send zap" }, SendZap: { defaultMessage: "Send zap" },
SendSats: { defaultMessage: "Send sats" },
ToTarget: { defaultMessage: "{action} to {target}" },
ShowReplies: { defaultMessage: "Show replies" }, ShowReplies: { defaultMessage: "Show replies" },
TooShort: { defaultMessage: "name too short" }, TooShort: { defaultMessage: "name too short" },
TooLong: { defaultMessage: "name too long" }, TooLong: { defaultMessage: "name too long" },

View File

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

View File

@ -47,6 +47,8 @@ const Translators = [
bech32ToHex("npub1z9n5ktfjrlpyywds9t7ljekr9cm9jjnzs27h702te5fy8p2c4dgs5zvycf"), // Felix - DE bech32ToHex("npub1z9n5ktfjrlpyywds9t7ljekr9cm9jjnzs27h702te5fy8p2c4dgs5zvycf"), // Felix - DE
bech32ToHex("npub1wh30wunfpkezx5s7edqu9g0s0raeetf5dgthzm0zw7sk8wqygmjqqfljgh"), // Fernando Porazzi - pt-BR bech32ToHex("npub1wh30wunfpkezx5s7edqu9g0s0raeetf5dgthzm0zw7sk8wqygmjqqfljgh"), // Fernando Porazzi - pt-BR
bech32ToHex("npub1ust7u0v3qffejwhqee45r49zgcyewrcn99vdwkednd356c9resyqtnn3mj"), // Petri - FI
]; ];
export const DonateLNURL = "donate@snort.social"; export const DonateLNURL = "donate@snort.social";

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { NostrEvent, TaggedNostrEvent } from "@snort/system"; import { NostrEvent, TaggedNostrEvent } from "@snort/system";
import { ZapTarget } from "Zapper";
interface NoteCreatorStore { interface NoteCreatorStore {
show: boolean; show: boolean;
@ -10,7 +11,7 @@ interface NoteCreatorStore {
replyTo?: TaggedNostrEvent; replyTo?: TaggedNostrEvent;
showAdvanced: boolean; showAdvanced: boolean;
selectedCustomRelays: false | Array<string>; selectedCustomRelays: false | Array<string>;
zapForward: string; zapSplits?: Array<ZapTarget>;
sensitive: string; sensitive: string;
pollOptions?: Array<string>; pollOptions?: Array<string>;
otherEvents: Array<NostrEvent>; otherEvents: Array<NostrEvent>;
@ -23,7 +24,6 @@ const InitState: NoteCreatorStore = {
active: false, active: false,
showAdvanced: false, showAdvanced: false,
selectedCustomRelays: false, selectedCustomRelays: false,
zapForward: "",
sensitive: "", sensitive: "",
otherEvents: [], otherEvents: [],
}; };
@ -56,9 +56,6 @@ const NoteCreatorSlice = createSlice({
setSelectedCustomRelays: (state, action: PayloadAction<false | Array<string>>) => { setSelectedCustomRelays: (state, action: PayloadAction<false | Array<string>>) => {
state.selectedCustomRelays = action.payload; state.selectedCustomRelays = action.payload;
}, },
setZapForward: (state, action: PayloadAction<string>) => {
state.zapForward = action.payload;
},
setSensitive: (state, action: PayloadAction<string>) => { setSensitive: (state, action: PayloadAction<string>) => {
state.sensitive = action.payload; state.sensitive = action.payload;
}, },
@ -68,6 +65,9 @@ const NoteCreatorSlice = createSlice({
setOtherEvents: (state, action: PayloadAction<Array<NostrEvent>>) => { setOtherEvents: (state, action: PayloadAction<Array<NostrEvent>>) => {
state.otherEvents = action.payload; state.otherEvents = action.payload;
}, },
setZapSplits: (state, action: PayloadAction<Array<ZapTarget>>) => {
state.zapSplits = action.payload;
},
reset: () => InitState, reset: () => InitState,
}, },
}); });
@ -81,7 +81,7 @@ export const {
setReplyTo, setReplyTo,
setShowAdvanced, setShowAdvanced,
setSelectedCustomRelays, setSelectedCustomRelays,
setZapForward, setZapSplits,
setSensitive, setSensitive,
setPollOptions, setPollOptions,
setOtherEvents, 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; flex: 2;
} }
.f-3 {
flex: 3;
}
.f-4 {
flex: 4;
}
.f-grow { .f-grow {
flex-grow: 1; flex-grow: 1;
min-width: 0; min-width: 0;
@ -421,6 +429,10 @@ input:disabled {
gap: 24px; gap: 24px;
} }
.txt-center {
text-align: center;
}
.w-max { .w-max {
width: 100%; width: 100%;
width: stretch; width: stretch;

View File

@ -36,6 +36,9 @@
"/RD0e2": { "/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." "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": { "/d6vEc": {
"defaultMessage": "Make your profile easier to find and share" "defaultMessage": "Make your profile easier to find and share"
}, },
@ -157,6 +160,9 @@
"5BVs2e": { "5BVs2e": {
"defaultMessage": "zap" "defaultMessage": "zap"
}, },
"5CB6zB": {
"defaultMessage": "Zap Splits"
},
"5JcXdV": { "5JcXdV": {
"defaultMessage": "Create Account" "defaultMessage": "Create Account"
}, },
@ -181,6 +187,9 @@
"6Yfvvp": { "6Yfvvp": {
"defaultMessage": "Get an identifier" "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": { "6ewQqw": {
"defaultMessage": "Likes ({n})" "defaultMessage": "Likes ({n})"
}, },
@ -196,9 +205,6 @@
"7hp70g": { "7hp70g": {
"defaultMessage": "NIP-05" "defaultMessage": "NIP-05"
}, },
"7xzTiH": {
"defaultMessage": "{action} to {target}"
},
"8/vBbP": { "8/vBbP": {
"defaultMessage": "Reposts ({n})" "defaultMessage": "Reposts ({n})"
}, },
@ -208,6 +214,12 @@
"8QDesP": { "8QDesP": {
"defaultMessage": "Zap {n} sats" "defaultMessage": "Zap {n} sats"
}, },
"8Rkoyb": {
"defaultMessage": "Recipient"
},
"8Y6bZQ": {
"defaultMessage": "Invalid zap split: {input}"
},
"8g2vyB": { "8g2vyB": {
"defaultMessage": "name too long" "defaultMessage": "name too long"
}, },
@ -217,6 +229,9 @@
"9+Ddtu": { "9+Ddtu": {
"defaultMessage": "Next" "defaultMessage": "Next"
}, },
"91VPqq": {
"defaultMessage": "Paying with wallet"
},
"9HU8vw": { "9HU8vw": {
"defaultMessage": "Reply" "defaultMessage": "Reply"
}, },
@ -367,9 +382,6 @@
"FDguSC": { "FDguSC": {
"defaultMessage": "{n} Zaps" "defaultMessage": "{n} Zaps"
}, },
"FP+D3H": {
"defaultMessage": "LNURL to forward zaps to"
},
"FS3b54": { "FS3b54": {
"defaultMessage": "Done!" "defaultMessage": "Done!"
}, },
@ -464,6 +476,9 @@
"JCIgkj": { "JCIgkj": {
"defaultMessage": "Username" "defaultMessage": "Username"
}, },
"JGrt9q": {
"defaultMessage": "Send sats to {name}"
},
"JHEHCk": { "JHEHCk": {
"defaultMessage": "Zaps ({n})" "defaultMessage": "Zaps ({n})"
}, },
@ -521,6 +536,9 @@
"Lw+I+J": { "Lw+I+J": {
"defaultMessage": "{n,plural,=0{{name} zapped} other{{name} & {n} others zapped}}" "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": { "M3Oirc": {
"defaultMessage": "Debug Menus" "defaultMessage": "Debug Menus"
}, },
@ -588,9 +606,6 @@
"ORGv1Q": { "ORGv1Q": {
"defaultMessage": "Created" "defaultMessage": "Created"
}, },
"P04gQm": {
"defaultMessage": "All zaps sent to this note will be received by the following LNURL"
},
"P61BTu": { "P61BTu": {
"defaultMessage": "Copy Event JSON" "defaultMessage": "Copy Event JSON"
}, },
@ -634,9 +649,6 @@
"R/6nsx": { "R/6nsx": {
"defaultMessage": "Subscription" "defaultMessage": "Subscription"
}, },
"R1fEdZ": {
"defaultMessage": "Forward Zaps"
},
"R81upa": { "R81upa": {
"defaultMessage": "People you follow" "defaultMessage": "People you follow"
}, },
@ -666,6 +678,9 @@
"defaultMessage": "Sort", "defaultMessage": "Sort",
"description": "Label for sorting options for people search" "description": "Label for sorting options for people search"
}, },
"SMO+on": {
"defaultMessage": "Send zap to {name}"
},
"SOqbe9": { "SOqbe9": {
"defaultMessage": "Update Lightning Address" "defaultMessage": "Update Lightning Address"
}, },
@ -756,12 +771,18 @@
"WONP5O": { "WONP5O": {
"defaultMessage": "Find your twitter follows on nostr (Data provided by {provider})" "defaultMessage": "Find your twitter follows on nostr (Data provided by {provider})"
}, },
"WvGmZT": {
"defaultMessage": "npub / nprofile / nostr address"
},
"WxthCV": { "WxthCV": {
"defaultMessage": "e.g. Jack" "defaultMessage": "e.g. Jack"
}, },
"X7xU8J": { "X7xU8J": {
"defaultMessage": "nsec, npub, nip-05, hex, mnemonic" "defaultMessage": "nsec, npub, nip-05, hex, mnemonic"
}, },
"XECMfW": {
"defaultMessage": "Send usage metrics"
},
"XICsE8": { "XICsE8": {
"defaultMessage": "File hosts" "defaultMessage": "File hosts"
}, },
@ -799,6 +820,9 @@
"ZLmyG9": { "ZLmyG9": {
"defaultMessage": "Contributors" "defaultMessage": "Contributors"
}, },
"ZS+jRE": {
"defaultMessage": "Send zap splits to"
},
"ZUZedV": { "ZUZedV": {
"defaultMessage": "Lightning Donation:" "defaultMessage": "Lightning Donation:"
}, },
@ -1209,6 +1233,9 @@
"sWnYKw": { "sWnYKw": {
"defaultMessage": "Snort is designed to have a similar experience to Twitter." "defaultMessage": "Snort is designed to have a similar experience to Twitter."
}, },
"sZQzjQ": {
"defaultMessage": "Failed to parse zap split: {input}"
},
"svOoEH": { "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." "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": { "u4bHcR": {
"defaultMessage": "Check out the code here: {link}" "defaultMessage": "Check out the code here: {link}"
}, },
"uD/N6c": {
"defaultMessage": "Zap {target} {n} sats"
},
"uSV4Ti": { "uSV4Ti": {
"defaultMessage": "Reposts need to be manually confirmed" "defaultMessage": "Reposts need to be manually confirmed"
}, },
"uc0din": {
"defaultMessage": "Send sats splits to"
},
"usAvMr": { "usAvMr": {
"defaultMessage": "Edit Profile" "defaultMessage": "Edit Profile"
}, },
@ -1248,9 +1275,6 @@
"vOKedj": { "vOKedj": {
"defaultMessage": "{n,plural,=1{& {n} other} other{& {n} others}}" "defaultMessage": "{n,plural,=1{& {n} other} other{& {n} others}}"
}, },
"vU71Ez": {
"defaultMessage": "Paying with {wallet}"
},
"vZ4quW": { "vZ4quW": {
"defaultMessage": "NIP-05 is a DNS based verification spec which helps to validate you as a real user." "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", "defaultMessage": "Read global from",
"description": "Label for reading global feed from specific relays" "description": "Label for reading global feed from specific relays"
}, },
"zCb8fX": {
"defaultMessage": "Weight"
},
"zFegDD": { "zFegDD": {
"defaultMessage": "Contact" "defaultMessage": "Contact"
}, },

View File

@ -11,6 +11,7 @@
"/JE/X+": "Account Support", "/JE/X+": "Account Support",
"/PCavi": "Public", "/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.", "/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", "/d6vEc": "Make your profile easier to find and share",
"/n5KSF": "{n} ms", "/n5KSF": "{n} ms",
"00LcfG": "Load more", "00LcfG": "Load more",
@ -51,6 +52,7 @@
"4Z3t5i": "Use imgproxy to compress images", "4Z3t5i": "Use imgproxy to compress images",
"4rYCjn": "Note to Self", "4rYCjn": "Note to Self",
"5BVs2e": "zap", "5BVs2e": "zap",
"5CB6zB": "Zap Splits",
"5JcXdV": "Create Account", "5JcXdV": "Create Account",
"5oTnfy": "Buy Handle", "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.", "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", "5ykRmX": "Send zap",
"65BmHb": "Failed to proxy image from {host}, click here to load directly", "65BmHb": "Failed to proxy image from {host}, click here to load directly",
"6Yfvvp": "Get an identifier", "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})", "6ewQqw": "Likes ({n})",
"6uMqL1": "Unpaid", "6uMqL1": "Unpaid",
"7+Domh": "Notes", "7+Domh": "Notes",
"7BX/yC": "Account Switcher", "7BX/yC": "Account Switcher",
"7hp70g": "NIP-05", "7hp70g": "NIP-05",
"7xzTiH": "{action} to {target}",
"8/vBbP": "Reposts ({n})", "8/vBbP": "Reposts ({n})",
"89q5wc": "Confirm Reposts", "89q5wc": "Confirm Reposts",
"8QDesP": "Zap {n} sats", "8QDesP": "Zap {n} sats",
"8Y6bZQ": "Invalid zap split: {input}",
"8g2vyB": "name too long", "8g2vyB": "name too long",
"8v1NN+": "Pairing phrase", "8v1NN+": "Pairing phrase",
"9+Ddtu": "Next", "9+Ddtu": "Next",
@ -120,7 +123,6 @@
"F+B3x1": "We have also partnered with nostrplebs.com to give you more options", "F+B3x1": "We have also partnered with nostrplebs.com to give you more options",
"F3l7xL": "Add Account", "F3l7xL": "Add Account",
"FDguSC": "{n} Zaps", "FDguSC": "{n} Zaps",
"FP+D3H": "LNURL to forward zaps to",
"FS3b54": "Done!", "FS3b54": "Done!",
"FSYL8G": "Trending Users", "FSYL8G": "Trending Users",
"FdhSU2": "Claim Now", "FdhSU2": "Claim Now",
@ -143,6 +145,7 @@
"HOzFdo": "Muted", "HOzFdo": "Muted",
"HWbkEK": "Clear cache and reload", "HWbkEK": "Clear cache and reload",
"HbefNb": "Open Wallet", "HbefNb": "Open Wallet",
"I9zn6f": "Pubkey",
"IDjHJ6": "Thanks for using Snort, please consider donating if you can.", "IDjHJ6": "Thanks for using Snort, please consider donating if you can.",
"IEwZvs": "Are you sure you want to unpin this note?", "IEwZvs": "Are you sure you want to unpin this note?",
"IKKHqV": "Follows", "IKKHqV": "Follows",
@ -152,6 +155,7 @@
"Ix8l+B": "Trending Notes", "Ix8l+B": "Trending Notes",
"J+dIsA": "Subscriptions", "J+dIsA": "Subscriptions",
"JCIgkj": "Username", "JCIgkj": "Username",
"JGrt9q": "Send sats to {name}",
"JHEHCk": "Zaps ({n})", "JHEHCk": "Zaps ({n})",
"JPFYIM": "No lightning address", "JPFYIM": "No lightning address",
"JeoS4y": "Repost", "JeoS4y": "Repost",
@ -171,6 +175,7 @@
"LgbKvU": "Comment", "LgbKvU": "Comment",
"Lu5/Bj": "Open on Zapstr", "Lu5/Bj": "Open on Zapstr",
"Lw+I+J": "{n,plural,=0{{name} zapped} other{{name} & {n} others zapped}}", "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", "M3Oirc": "Debug Menus",
"MBAYRO": "Shows \"Copy ID\" and \"Copy Event JSON\" in the context menu on each message", "MBAYRO": "Shows \"Copy ID\" and \"Copy Event JSON\" in the context menu on each message",
"MI2jkA": "Not available:", "MI2jkA": "Not available:",
@ -193,7 +198,6 @@
"OLEm6z": "Unknown login error", "OLEm6z": "Unknown login error",
"OQXnew": "You subscription is still active, you can't renew yet", "OQXnew": "You subscription is still active, you can't renew yet",
"ORGv1Q": "Created", "ORGv1Q": "Created",
"P04gQm": "All zaps sent to this note will be received by the following LNURL",
"P61BTu": "Copy Event JSON", "P61BTu": "Copy Event JSON",
"P7FD0F": "System (Default)", "P7FD0F": "System (Default)",
"P7nJT9": "Total today (UTC): {amount} sats", "P7nJT9": "Total today (UTC): {amount} sats",
@ -208,7 +212,6 @@
"QxCuTo": "Art by {name}", "QxCuTo": "Art by {name}",
"Qxv0B2": "You currently have {number} sats in your zap pool.", "Qxv0B2": "You currently have {number} sats in your zap pool.",
"R/6nsx": "Subscription", "R/6nsx": "Subscription",
"R1fEdZ": "Forward Zaps",
"R81upa": "People you follow", "R81upa": "People you follow",
"RDZVQL": "Check", "RDZVQL": "Check",
"RahCRH": "Expired", "RahCRH": "Expired",
@ -218,6 +221,7 @@
"RoOyAh": "Relays", "RoOyAh": "Relays",
"Rs4kCE": "Bookmark", "Rs4kCE": "Bookmark",
"RwFaYs": "Sort", "RwFaYs": "Sort",
"SMO+on": "Send zap to {name}",
"SOqbe9": "Update Lightning Address", "SOqbe9": "Update Lightning Address",
"SP0+yi": "Buy Subscription", "SP0+yi": "Buy Subscription",
"SX58hM": "Copy", "SX58hM": "Copy",
@ -247,8 +251,10 @@
"W2PiAr": "{n} Blocked", "W2PiAr": "{n} Blocked",
"W9355R": "Unmute", "W9355R": "Unmute",
"WONP5O": "Find your twitter follows on nostr (Data provided by {provider})", "WONP5O": "Find your twitter follows on nostr (Data provided by {provider})",
"WvGmZT": "npub / nprofile / nostr address",
"WxthCV": "e.g. Jack", "WxthCV": "e.g. Jack",
"X7xU8J": "nsec, npub, nip-05, hex, mnemonic", "X7xU8J": "nsec, npub, nip-05, hex, mnemonic",
"XECMfW": "Send usage metrics",
"XICsE8": "File hosts", "XICsE8": "File hosts",
"XgWvGA": "Reactions", "XgWvGA": "Reactions",
"Xopqkl": "Your default zap amount is {number} sats, example values are calculated from this.", "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", "rudscU": "Failed to load follows, please try again later",
"sUNhQE": "user", "sUNhQE": "user",
"sWnYKw": "Snort is designed to have a similar experience to Twitter.", "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.", "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", "tOdNiY": "Dark",
"th5lxp": "Send note to a subset of your write relays", "th5lxp": "Send note to a subset of your write relays",
@ -402,7 +409,6 @@
"ttxS0b": "Supporter Badge", "ttxS0b": "Supporter Badge",
"u/vOPu": "Paid", "u/vOPu": "Paid",
"u4bHcR": "Check out the code here: {link}", "u4bHcR": "Check out the code here: {link}",
"uD/N6c": "Zap {target} {n} sats",
"uSV4Ti": "Reposts need to be manually confirmed", "uSV4Ti": "Reposts need to be manually confirmed",
"usAvMr": "Edit Profile", "usAvMr": "Edit Profile",
"ut+2Cd": "Get a partner identifier", "ut+2Cd": "Get a partner identifier",
@ -436,6 +442,7 @@
"y1Z3or": "Language", "y1Z3or": "Language",
"yCLnBC": "LNURL or Lightning Address", "yCLnBC": "LNURL or Lightning Address",
"yCmnnm": "Read global from", "yCmnnm": "Read global from",
"zCb8fX": "Weight",
"zFegDD": "Contact", "zFegDD": "Contact",
"zINlao": "Owner", "zINlao": "Owner",
"zQvVDJ": "All", "zQvVDJ": "All",

View File

@ -92,17 +92,6 @@ export class LNURL {
return `${username}@${this.#url.hostname}`; 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() { async load() {
const rsp = await fetch(this.#url); const rsp = await fetch(this.#url);
if (rsp.ok) { if (rsp.ok) {

View File

@ -43,6 +43,10 @@ export function unixNowMs() {
return new Date().getTime(); return new Date().getTime();
} }
export function jitter(n: number) {
return n * 2 * Math.random() - n;
}
export function deepClone<T>(obj: T) { export function deepClone<T>(obj: T) {
if ("structuredClone" in window) { if ("structuredClone" in window) {
return structuredClone(obj); return structuredClone(obj);

View File

@ -1,6 +1,6 @@
import { EventKind, HexKey, NostrPrefix, NostrEvent, EventSigner, PowMiner } from "."; import { EventKind, HexKey, NostrPrefix, NostrEvent, EventSigner, PowMiner } from ".";
import { HashtagRegex, MentionNostrEntityRegex } from "./const"; import { HashtagRegex, MentionNostrEntityRegex } from "./const";
import { getPublicKey, unixNow } from "@snort/shared"; import { getPublicKey, jitter, unixNow } from "@snort/shared";
import { EventExt } from "./event-ext"; import { EventExt } from "./event-ext";
import { tryParseNostrLink } from "./nostr-link"; import { tryParseNostrLink } from "./nostr-link";
@ -12,6 +12,12 @@ export class EventBuilder {
#tags: Array<Array<string>> = []; #tags: Array<Array<string>> = [];
#pow?: number; #pow?: number;
#powMiner?: PowMiner; #powMiner?: PowMiner;
#jitter?: number;
jitter(n: number) {
this.#jitter = n;
return this;
}
kind(k: EventKind) { kind(k: EventKind) {
this.#kind = k; this.#kind = k;
@ -73,8 +79,8 @@ export class EventBuilder {
pubkey: this.#pubkey ?? "", pubkey: this.#pubkey ?? "",
content: this.#content ?? "", content: this.#content ?? "",
kind: this.#kind, kind: this.#kind,
created_at: this.#createdAt ?? unixNow(), created_at: (this.#createdAt ?? unixNow()) + (this.#jitter ? jitter(this.#jitter) : 0),
tags: this.#tags, tags: this.#tags.sort((a, b) => a[0].localeCompare(b[0])),
} as NostrEvent; } as NostrEvent;
ev.id = EventExt.createId(ev); ev.id = EventExt.createId(ev);
return ev; return ev;

View File

@ -11,6 +11,17 @@ export interface NostrLink {
encode(): string; 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) { export function createNostrLinkToEvent(ev: TaggedNostrEvent | NostrEvent) {
const relays = "relays" in ev ? ev.relays : undefined; 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() { async #FetchMetadata() {
const missingFromCache = await this.#cache.buffer([...this.#wantsMetadata]); const missingFromCache = await this.#cache.buffer([...this.#wantsMetadata]);