split SendSats

This commit is contained in:
Martti Malmi 2024-01-11 23:33:24 +02:00
parent 649bab228b
commit c8dae9fae6
9 changed files with 326 additions and 271 deletions

View File

@ -20,7 +20,16 @@ module.exports = {
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"@typescript-eslint/no-unused-vars": "error",
"max-lines": ["warn", { max: 300, skipBlankLines: true, skipComments: true }],
},
overrides: [
{
files: ["*.tsx"],
rules: {
"max-lines": ["warn", { max: 200, skipBlankLines: true, skipComments: true }],
},
},
],
root: true,
ignorePatterns: ["build/", "*.test.ts", "*.js"],
env: {

View File

@ -3,30 +3,18 @@ import "./SendSats.css";
import { LNURLSuccessAction } from "@snort/shared";
import { HexKey } from "@snort/system";
import React, { ReactNode, useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import CloseButton from "@/Components/Button/CloseButton";
import Copy from "@/Components/Copy/Copy";
import Icon from "@/Components/Icons/Icon";
import Modal from "@/Components/Modal/Modal";
import QrCode from "@/Components/QrCode";
import ProfileImage from "@/Components/User/ProfileImage";
import { SendSatsInput, SendSatsInputSelection } from "@/Components/SendSats/SendSatsInput";
import { SendSatsInvoice } from "@/Components/SendSats/SendSatsInvoice";
import { SendSatsTitle } from "@/Components/SendSats/SendSatsTitle";
import { SuccessAction } from "@/Components/SendSats/SuccessAction";
import { ZapType } from "@/Components/SendSats/ZapType";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import { debounce } from "@/Utils";
import { formatShort } from "@/Utils/Number";
import { Zapper, ZapTarget, ZapTargetResult } from "@/Utils/Zapper";
import { LNWallet, useWallet } from "@/Wallet";
import messages from "../messages";
enum ZapType {
PublicZap = 1,
AnonZap = 2,
PrivateZap = 3,
NonZap = 4,
}
import { useWallet } from "@/Wallet";
export interface SendSatsProps {
onClose?: () => void;
@ -97,91 +85,14 @@ export default function SendSats(props: SendSatsProps) {
}
}, [props.targets, props.show]);
function successAction() {
if (!success) return null;
return (
<div className="flex items-center">
<p className="flex g12">
<Icon name="check" className="success" />
{success?.description ?? <FormattedMessage defaultMessage="Paid" id="u/vOPu" />}
</p>
{success.url && (
<p>
<a href={success.url} rel="noreferrer" target="_blank">
{success.url}
</a>
</p>
)}
</div>
);
}
function title() {
if (!props.targets) {
return (
<>
<h2>
{zapper?.canZap() ? (
<FormattedMessage defaultMessage="Send zap" id="5ykRmX" />
) : (
<FormattedMessage defaultMessage="Send sats" id="DKnriN" />
)}
</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}" id="SMO+on" values={values} />
) : (
<FormattedMessage defaultMessage="Send sats to {name}" id="JGrt9q" values={values} />
)}
</h2>
</>
);
}
if (props.targets.length > 1) {
const total = props.targets.reduce((acc, v) => (acc += v.weight), 0);
return (
<div className="flex flex-col g12">
<h2>
{zapper?.canZap() ? (
<FormattedMessage defaultMessage="Send zap splits to" id="ZS+jRE" />
) : (
<FormattedMessage defaultMessage="Send sats splits to" id="uc0din" />
)}
</h2>
<div className="flex g4 f-wrap">
{props.targets.map(v => (
<ProfileImage
key={v.value}
pubkey={v.value}
showUsername={false}
showFollowDistance={false}
imageOverlay={formatShort(Math.floor((amount?.amount ?? 0) * (v.weight / total)))}
/>
))}
</div>
</div>
);
}
}
if (!(props.show ?? false)) return null;
return (
<Modal id="send-sats" className="lnurl-modal" onClose={onClose}>
<div className="p flex flex-col g12">
<div className="flex g12">
<div className="flex items-center grow">{props.title || title()}</div>
<div className="flex items-center grow">
{props.title || <SendSatsTitle amount={amount} targets={props.targets} zapper={zapper} />}
</div>
<CloseButton onClick={onClose} />
</div>
{zapper && !invoice && (
@ -227,180 +138,8 @@ export default function SendSats(props: SendSatsProps) {
}}
/>
)}
{successAction()}
{success && <SuccessAction success={success} />}
</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 { defaultZapAmount, readonly } = useLogin(s => ({
defaultZapAmount: s.appData.item.preferences.defaultZapAmount,
readonly: s.readonly,
}));
const { formatMessage } = useIntl();
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(readonly ? ZapType.AnonZap : 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="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 flex-col g24">
<div className="flex flex-col g8">
<h3>
<FormattedMessage defaultMessage="Zap amount in sats" id="zcaOTs" />
</h3>
{renderAmounts()}
{custom()}
{props.zapper.maxComment() > 0 && (
<input
type="text"
placeholder={formatMessage(messages.Comment)}
className="grow"
maxLength={props.zapper.maxComment()}
onChange={e => setComment(e.target.value)}
/>
)}
</div>
<SendSatsZapTypeSelector zapType={zapType} setZapType={setZapType} />
{(amount ?? 0) > 0 && (
<AsyncButton onClick={() => props.onNextStage(getValue())}>
<Icon name="zap" />
<FormattedMessage defaultMessage="Zap {n} sats" id="8QDesP" values={{ n: formatShort(amount) }} />
</AsyncButton>
)}
</div>
);
}
function SendSatsZapTypeSelector({ zapType, setZapType }: { zapType: ZapType; setZapType: (t: ZapType) => void }) {
const { readonly } = useLogin(s => ({ readonly: s.readonly }));
const makeTab = (t: ZapType, n: React.ReactNode) => (
<button type="button" className={zapType === t ? "" : "secondary"} onClick={() => setZapType(t)}>
{n}
</button>
);
return (
<div className="flex flex-col g8">
<h3>
<FormattedMessage defaultMessage="Zap Type" id="+aZY2h" />
</h3>
<div className="flex g8">
{!readonly &&
makeTab(ZapType.PublicZap, <FormattedMessage defaultMessage="Public" id="/PCavi" description="Public Zap" />)}
{/*makeTab(ZapType.PrivateZap, "Private")*/}
{makeTab(ZapType.AnonZap, <FormattedMessage defaultMessage="Anon" id="wWLwvh" description="Anonymous Zap" />)}
{makeTab(
ZapType.NonZap,
<FormattedMessage defaultMessage="Non-Zap" id="AnLrRC" description="Non-Zap, Regular LN payment" />,
)}
</div>
</div>
);
}
function SendSatsInvoice(props: {
invoice: Array<ZapTargetResult>;
wallet?: LNWallet;
notice?: ReactNode;
onInvoicePaid: () => void;
}) {
return (
<div className="flex flex-col items-center g12 txt-center">
{props.notice && <b className="error">{props.notice}</b>}
{props.invoice.map(v => (
<>
<QrCode data={v.pr} link={`lightning:${v.pr}`} />
<div className="flex flex-col g12">
<Copy text={v.pr} maxSize={26} className="items-center" />
<a href={`lightning:${v.pr}`}>
<button type="button">
<FormattedMessage defaultMessage="Open Wallet" id="HbefNb" />
</button>
</a>
</div>
</>
))}
</div>
);
}

View File

@ -0,0 +1,131 @@
import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import AsyncButton from "@/Components/Button/AsyncButton";
import Icon from "@/Components/Icons/Icon";
import messages from "@/Components/messages";
import { SendSatsZapTypeSelector } from "@/Components/SendSats/SendSatsZapTypeSelector";
import { ZapType } from "@/Components/SendSats/ZapType";
import useLogin from "@/Hooks/useLogin";
import { formatShort } from "@/Utils/Number";
import { Zapper } from "@/Utils/Zapper";
export interface SendSatsInputSelection {
amount: number;
comment?: string;
type: ZapType;
}
export function SendSatsInput(props: {
zapper: Zapper;
onChange?: (v: SendSatsInputSelection) => void;
onNextStage: (v: SendSatsInputSelection) => Promise<void>;
}) {
const { defaultZapAmount, readonly } = useLogin(s => ({
defaultZapAmount: s.appData.item.preferences.defaultZapAmount,
readonly: s.readonly,
}));
const { formatMessage } = useIntl();
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(readonly ? ZapType.AnonZap : 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="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 flex-col g24">
<div className="flex flex-col g8">
<h3>
<FormattedMessage defaultMessage="Zap amount in sats" id="zcaOTs" />
</h3>
{renderAmounts()}
{custom()}
{props.zapper.maxComment() > 0 && (
<input
type="text"
placeholder={formatMessage(messages.Comment)}
className="grow"
maxLength={props.zapper.maxComment()}
onChange={e => setComment(e.target.value)}
/>
)}
</div>
<SendSatsZapTypeSelector zapType={zapType} setZapType={setZapType} />
{(amount ?? 0) > 0 && (
<AsyncButton onClick={() => props.onNextStage(getValue())}>
<Icon name="zap" />
<FormattedMessage defaultMessage="Zap {n} sats" id="8QDesP" values={{ n: formatShort(amount) }} />
</AsyncButton>
)}
</div>
);
}

View File

@ -0,0 +1,33 @@
import React, { ReactNode } from "react";
import { FormattedMessage } from "react-intl";
import Copy from "@/Components/Copy/Copy";
import QrCode from "@/Components/QrCode";
import { ZapTargetResult } from "@/Utils/Zapper";
import { LNWallet } from "@/Wallet";
export function SendSatsInvoice(props: {
invoice: Array<ZapTargetResult>;
wallet?: LNWallet;
notice?: ReactNode;
onInvoicePaid: () => void;
}) {
return (
<div className="flex flex-col items-center g12 txt-center">
{props.notice && <b className="error">{props.notice}</b>}
{props.invoice.map(v => (
<>
<QrCode data={v.pr} link={`lightning:${v.pr}`} />
<div className="flex flex-col g12">
<Copy text={v.pr} maxSize={26} className="items-center" />
<a href={`lightning:${v.pr}`}>
<button type="button">
<FormattedMessage defaultMessage="Open Wallet" id="HbefNb" />
</button>
</a>
</div>
</>
))}
</div>
);
}

View File

@ -0,0 +1,75 @@
import React from "react";
import { FormattedMessage } from "react-intl";
import { SendSatsInputSelection } from "@/Components/SendSats/SendSatsInput";
import ProfileImage from "@/Components/User/ProfileImage";
import { formatShort } from "@/Utils/Number";
import { Zapper, ZapTarget } from "@/Utils/Zapper";
export function SendSatsTitle({
targets,
zapper,
amount,
}: {
targets?: Array<ZapTarget>;
zapper?: Zapper;
amount?: SendSatsInputSelection;
}) {
if (!targets) {
return (
<>
<h2>
{zapper?.canZap() ? (
<FormattedMessage defaultMessage="Send zap" id="5ykRmX" />
) : (
<FormattedMessage defaultMessage="Send sats" id="DKnriN" />
)}
</h2>
</>
);
}
if (targets.length === 1 && targets[0].name) {
const t = 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}" id="SMO+on" values={values} />
) : (
<FormattedMessage defaultMessage="Send sats to {name}" id="JGrt9q" values={values} />
)}
</h2>
</>
);
}
if (targets.length > 1) {
const total = targets.reduce((acc, v) => (acc += v.weight), 0);
return (
<div className="flex flex-col g12">
<h2>
{zapper?.canZap() ? (
<FormattedMessage defaultMessage="Send zap splits to" id="ZS+jRE" />
) : (
<FormattedMessage defaultMessage="Send sats splits to" id="uc0din" />
)}
</h2>
<div className="flex g4 f-wrap">
{targets.map(v => (
<ProfileImage
key={v.value}
pubkey={v.value}
showUsername={false}
showFollowDistance={false}
imageOverlay={formatShort(Math.floor((amount?.amount ?? 0) * (v.weight / total)))}
/>
))}
</div>
</div>
);
}
}

View File

@ -0,0 +1,37 @@
import React from "react";
import { FormattedMessage } from "react-intl";
import { ZapType } from "@/Components/SendSats/ZapType";
import useLogin from "@/Hooks/useLogin";
export function SendSatsZapTypeSelector({
zapType,
setZapType,
}: {
zapType: ZapType;
setZapType: (t: ZapType) => void;
}) {
const { readonly } = useLogin(s => ({ readonly: s.readonly }));
const makeTab = (t: ZapType, n: React.ReactNode) => (
<button type="button" className={zapType === t ? "" : "secondary"} onClick={() => setZapType(t)}>
{n}
</button>
);
return (
<div className="flex flex-col g8">
<h3>
<FormattedMessage defaultMessage="Zap Type" id="+aZY2h" />
</h3>
<div className="flex g8">
{!readonly &&
makeTab(ZapType.PublicZap, <FormattedMessage defaultMessage="Public" id="/PCavi" description="Public Zap" />)}
{/*makeTab(ZapType.PrivateZap, "Private")*/}
{makeTab(ZapType.AnonZap, <FormattedMessage defaultMessage="Anon" id="wWLwvh" description="Anonymous Zap" />)}
{makeTab(
ZapType.NonZap,
<FormattedMessage defaultMessage="Non-Zap" id="AnLrRC" description="Non-Zap, Regular LN payment" />,
)}
</div>
</div>
);
}

View File

@ -0,0 +1,23 @@
import { LNURLSuccessAction } from "@snort/shared";
import React from "react";
import { FormattedMessage } from "react-intl";
import Icon from "@/Components/Icons/Icon";
export function SuccessAction({ success }: { success: LNURLSuccessAction }) {
return (
<div className="flex items-center">
<p className="flex g12">
<Icon name="check" className="success" />
{success?.description ?? <FormattedMessage defaultMessage="Paid" id="u/vOPu" />}
</p>
{success.url && (
<p>
<a href={success.url} rel="noreferrer" target="_blank">
{success.url}
</a>
</p>
)}
</div>
);
}

View File

@ -0,0 +1,6 @@
export enum ZapType {
PublicZap = 1,
AnonZap = 2,
PrivateZap = 3,
NonZap = 4,
}

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines */
import { sha256 } from "@noble/hashes/sha256";
const animals = [