Support Cashu Ecash

This commit is contained in:
Bojan Mojsilovic 2024-04-08 15:28:51 +02:00
parent 8f13127d6b
commit c6807a9cd8
11 changed files with 988 additions and 4 deletions

137
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.104.0", "version": "0.104.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@cashu/cashu-ts": "^0.9.0",
"@cookbook/solid-intl": "0.1.2", "@cookbook/solid-intl": "0.1.2",
"@jukben/emoji-search": "3.0.0", "@jukben/emoji-search": "3.0.0",
"@kobalte/core": "0.11.0", "@kobalte/core": "0.11.0",
@ -491,6 +492,61 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@cashu/cashu-ts": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-0.9.0.tgz",
"integrity": "sha512-DacSnpv3dJIGzo6A1nHIp2guFuDcmoPB5CX9m0SXA60bQxoIa4srHKLkjxUZ8GFCD9RaI+60UZ2+hZS635Ro2w==",
"dependencies": {
"@gandlaf21/bolt11-decode": "^3.0.6",
"@noble/curves": "^1.0.0",
"@scure/bip32": "^1.3.2",
"@scure/bip39": "^1.2.1",
"buffer": "^6.0.3"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@noble/curves": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.0.tgz",
"integrity": "sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg==",
"dependencies": {
"@noble/hashes": "1.4.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@noble/hashes": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz",
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@scure/base": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.6.tgz",
"integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@scure/bip32": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.4.0.tgz",
"integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==",
"dependencies": {
"@noble/curves": "~1.4.0",
"@noble/hashes": "~1.4.0",
"@scure/base": "~1.1.6"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cookbook/solid-intl": { "node_modules/@cookbook/solid-intl": {
"version": "0.1.2", "version": "0.1.2",
"license": "MIT", "license": "MIT",
@ -635,6 +691,16 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@gandlaf21/bolt11-decode": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@gandlaf21/bolt11-decode/-/bolt11-decode-3.1.1.tgz",
"integrity": "sha512-uorLVc3FAWO6USCaBHGixwUd7B86z4IVRa/TdLicsWi3Vm7QngYDIuUkOBemut0Qh/Qtsx47EjWMXS4WHel98A==",
"dependencies": {
"bech32": "^1.1.2",
"bn.js": "^4.11.8",
"buffer": "^6.0.3"
}
},
"node_modules/@internationalized/date": { "node_modules/@internationalized/date": {
"version": "3.5.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.0.tgz", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.0.tgz",
@ -1043,6 +1109,30 @@
"@babel/core": "^7.0.0" "@babel/core": "^7.0.0"
} }
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/bech32": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz",
"integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ=="
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.2.0", "version": "2.2.0",
"license": "MIT", "license": "MIT",
@ -1050,6 +1140,11 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/bn.js": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
"integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA=="
},
"node_modules/braces": { "node_modules/braces": {
"version": "3.0.2", "version": "3.0.2",
"license": "MIT", "license": "MIT",
@ -1091,6 +1186,29 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001534", "version": "1.0.30001534",
"dev": true, "dev": true,
@ -1649,6 +1767,25 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/immutable": { "node_modules/immutable": {
"version": "4.3.4", "version": "4.3.4",
"license": "MIT" "license": "MIT"

View File

@ -21,6 +21,7 @@
"vite-plugin-solid": "^2.5.0" "vite-plugin-solid": "^2.5.0"
}, },
"dependencies": { "dependencies": {
"@cashu/cashu-ts": "0.9.0",
"@cookbook/solid-intl": "0.1.2", "@cookbook/solid-intl": "0.1.2",
"@jukben/emoji-search": "3.0.0", "@jukben/emoji-search": "3.0.0",
"@kobalte/core": "0.11.0", "@kobalte/core": "0.11.0",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -0,0 +1,335 @@
.cashu {
width: 100%;
min-height: 158px;
background-color: var(--background-header-input);
border-radius: var(--border-radius-small);
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
position: relative;
.paymentOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--background-site);
opacity: 0.6;
display: flex;
justify-content: center;
align-items: center;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 20px;
.title {
display: flex;
justify-content: flex-start;
align-items: center;
color: var(--text-primary);
font-size: 15px;
font-weight: 700;
line-height: 16px;
}
.headerActions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 2px;
button {
.qrIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/qr_code.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/qr_code.svg) no-repeat 0px / 18px;
}
.copyIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/copy_border.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/copy_border.svg) no-repeat 0px / 18px;
}
&:hover {
.qrIcon, .copyIcon {
background-color: var(--text-primary);
}
}
}
.copyDone {
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
.checkIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: var(--success-bright);
-webkit-mask: url(../../assets/icons/check.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/check.svg) no-repeat 0px / 18px;
}
}
}
}
.body {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: 8px;
.description {
color: var(--text-primary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
}
.amount {
color: var(--text-primary);
font-size: 24px;
font-weight: 600;
line-height: 24px;
}
}
.footer {
height: 36px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
.mint {
color: var(--text-secondary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.payAction {
height: 36px;
min-width: 120px;
display: flex;
button {
width: 100%;
height: 100%;
}
}
.spent {
height: 36px;
min-width: 120px;
margin-right: 12px;
display: flex;
justify-content: flex-end;
align-items: flex-end;
}
}
&.noBack {
background-color: unset;
}
.cashuIcon {
width: 20px;
height: 20px;
background-image: url('../../assets/icons/cashu.svg');
background-size: contain;
background-repeat: no-repeat;
}
}
.cashuAlter {
width: 100%;
min-height: 158px;
background-color: none;
border-radius: var(--border-radius-small);
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
position: relative;
.paymentOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--background-site);
opacity: 0.6;
display: flex;
justify-content: center;
align-items: center;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 20px;
.title {
display: flex;
justify-content: flex-start;
align-items: center;
color: white;
font-size: 15px;
font-weight: 700;
line-height: 16px;
}
.headerActions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 2px;
button {
.qrIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: white;
-webkit-mask: url(../../assets/icons/qr_code.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/qr_code.svg) no-repeat 0px / 18px;
}
.copyIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: white;
-webkit-mask: url(../../assets/icons/copy_border.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/copy_border.svg) no-repeat 0px / 18px;
}
&:hover {
.qrIcon, .copyIcon {
background-color: white;
}
}
}
.copyDone {
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
.checkIcon {
width: 18px;
height: 18px;
display: inline-block;
margin: 0px;
background-color: var(--success-bright);
-webkit-mask: url(../../assets/icons/check.svg) no-repeat 0px / 18px;
mask: url(../../assets/icons/check.svg) no-repeat 0px / 18px;
}
}
}
}
.body {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: 8px;
.description {
color: white;
font-size: 15px;
font-weight: 400;
line-height: 18px;
}
.amount {
color: white;
font-size: 24px;
font-weight: 600;
line-height: 24px;
}
}
.footer {
height: 36px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
.expiryDate {
color: white;
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.expiredDate {
color: white;
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.payAction {
height: 36px;
min-width: 120px;
button {
width: 100%;
height: 100%;
color: var(--accent);
background-color: white;
}
}
}
&.noBack {
background-color: unset;
}
.cashuIcon {
width: 20px;
height: 20px;
background-image: url('../../assets/icons/cashu.svg');
background-size: contain;
background-repeat: no-repeat;
}
}

View File

@ -0,0 +1,185 @@
import { Component, createEffect, createSignal, Match, onMount, Show, Switch } from 'solid-js';
import { hookForDev } from '../../lib/devTools';
import styles from './Cashu.module.scss';
import { createStore } from 'solid-js/store';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import ButtonGhost from '../Buttons/ButtonGhost';
import { useAppContext } from '../../contexts/AppContext';
import Loader from '../Loader/Loader';
import { logError } from '../../lib/logger';
import { useIntl } from '@cookbook/solid-intl';
import { cashuInvoice } from '../../translations';
import { getDecodedToken, Token, TokenEntry } from "@cashu/cashu-ts";
import { useAccountContext } from '../../contexts/AccountContext';
const Cashu: Component< { id?: string, token: string, alternative?: boolean, noBack?: boolean } > = (props) => {
const account = useAccountContext();
const app = useAppContext();
const intl = useIntl();
const [invoice, setInvoice] = createStore<Token>({ token: [] });
const [cashuSpendable, setCashuSpendable] = createSignal<boolean>(false);
const [invoiceCopied, setInvoiceCopied] = createSignal(false);
const [paymentInProgress, setPaymentInProgress] = createSignal(false);
const checkMints = async (entries: TokenEntry[]) => {
let statuses: boolean[] = [];
for (const entry of entries) {
const mint = app?.actions.getCashuMint(entry.mint);
if (!mint) continue;
const spent = await mint.check({ proofs: entry.proofs.map((p) => ({ secret: p.secret })) });
const data = spent.spendable.map(s => s);
statuses = [ ...statuses, ...data];
}
setCashuSpendable(() => !statuses.includes(false));
}
createEffect(() => {
if (invoice.token.length === 0) return;
checkMints(invoice.token);
});
createEffect(() => {
try {
const dec: Token = getDecodedToken(props.token);
setInvoice(() => ({ ...dec }));
} catch (e) {
logError('Failed to decode cashu token: ', e);
}
});
createEffect(() => {
if (invoiceCopied()) {
setTimeout(() => {
setInvoiceCopied(() => false);
}, 1_000);
}
})
const amount = () =>
`${invoice.token[0]?.proofs.reduce((acc, v) => acc + v.amount, 0) || 0} sats`;
const description = () => invoice.memo || '';
const confirmPayment = () => app?.actions.openConfirmModal({
title: intl.formatMessage(cashuInvoice.confirm.title),
description: intl.formatMessage(cashuInvoice.confirm.description, { amount: amount() }),
confirmLabel: intl.formatMessage(cashuInvoice.confirm.confirmLabel),
abortLabel: intl.formatMessage(cashuInvoice.confirm.abortLabel),
onAbort: app.actions.closeConfirmModal,
onConfirm: () => {
app.actions.closeConfirmModal();
redeemCashu();
},
});
const redeemCashu = () => {
const lnurl = account?.activeUser?.lud16 ?? '';
const url = `https://redeem.cashu.me?token=${encodeURIComponent(props.token)}&lightning=${encodeURIComponent(
lnurl,
)}&autopay=yes`;
console.log('Redeem: ', url);
window.open(url, 'blank_');
};
const klass = () => {
let k = props.alternative ? styles.cashuAlter : styles.cashu;
if (props.noBack) {
k += ` ${styles.noBack}`
}
return k;
}
return (
<div id={props.id} class={klass()}>
<Show when={paymentInProgress()}>
<div class={styles.paymentOverlay}>
<Loader />
</div>
</Show>
<div class={styles.header}>
<div class={styles.title}>
<div class={styles.cashuIcon}></div>
<div>{intl.formatMessage(cashuInvoice.title)}</div>
</div>
<div class={styles.headerActions}>
<Show when={cashuSpendable()}>
<ButtonGhost
onClick={(e: MouseEvent) => {
e.preventDefault();
app?.actions.openCashuModal(props.token, () => {
app.actions.closeCashuModal();
confirmPayment();
});
}}
shrink={true}
>
<div class={styles.qrIcon}></div>
</ButtonGhost>
</Show>
<Show
when={!invoiceCopied()}
fallback={<div class={styles.copyDone}><div class={styles.checkIcon}></div></div>}
>
<ButtonGhost
onClick={(e: MouseEvent) => {
e.preventDefault()
navigator.clipboard.writeText(props.token);
setInvoiceCopied(() => true);
}}
shrink={true}
>
<div class={styles.copyIcon}></div>
</ButtonGhost>
</Show>
</div>
</div>
<div class={styles.body}>
<div class={styles.description}>{description()}</div>
<div class={styles.amount}>{amount()}</div>
</div>
<div class={styles.footer}>
<div class={styles.mint}>
<Show when={invoice.token[0]}>
{intl.formatMessage(cashuInvoice.mint, { url: new URL(invoice.token[0]?.mint).hostname })}
</Show>
</div>
<Show
when={cashuSpendable()}
fallback={(
<div class={styles.spent}>
{intl.formatMessage(cashuInvoice.spent)}
</div>
)}
>
<div class={styles.payAction}>
<ButtonPrimary onClick={(e: MouseEvent) => {
e.preventDefault();
confirmPayment();
}}>
{intl.formatMessage(cashuInvoice.redeem)}
</ButtonPrimary>
</div>
</Show>
</div>
</div>
);
}
export default hookForDev(Cashu);

View File

@ -0,0 +1,120 @@
.CashuQrCodeModal {
position: fixed;
min-width: 472px;
color: var(--text-primary);
background-color: var(--background-input);
border-radius: 8px;
display: flex;
flex-direction: column;
padding: 20px 24px 28px 24px;
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: 24px;
.title {
color: var(--text-primary);
font-size: 20px;
font-weight: 700;
line-height: 20px;
}
.close {
border: none;
outline: none;
padding: 0;
margin: 0;
box-shadow: none;
width: 20px;
height: 20px;
display: inline-block;
margin: 0px 0px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/close.svg) no-repeat center;
mask: url(../../assets/icons/close.svg) no-repeat center;
&:hover {
background-color: var(--text-primary);
}
}
}
}
.body {
display: flex;
flex-direction: column;
gap: 12px;
.description {
color: var(--text-primary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
display: flex;
justify-content: center;
align-items: center;
text-align: left;
width: 100%;
}
.amount {
color: var(--text-primary);
font-size: 24px;
font-weight: 600;
line-height: 24px;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.separator {
width: 100%;
height: 1px;
border: 1px solid var(--subtile-devider);
}
}
.footer {
height: 36px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: 20px;
.mint {
color: var(--text-secondary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.payAction {
height: 36px;
min-width: 120px;
button {
width: 100%;
height: 100%;
}
}
}
.zapIcon {
width: 22px;
height: 22px;
display: inline-block;
margin-right: 9px;
background: var(--sidebar-section-icon-gradient);
-webkit-mask: url(../../assets/icons/explore/zaps_hollow.svg) no-repeat 2px 0 / 19px 22px;
mask: url(../../assets/icons/explore/zaps_hollow.svg) no-repeat 2px 0 / 19px 22px;
}

View File

@ -0,0 +1,83 @@
import { useIntl } from '@cookbook/solid-intl';
// @ts-ignore
import { decode } from 'light-bolt11-decoder';
import { Component, createEffect, Show } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { emptyInvoice } from '../../constants';
import { date, dateFuture } from '../../lib/dates';
import { hookForDev } from '../../lib/devTools';
import { humanizeNumber } from '../../lib/stats';
import { cashuInvoice } from '../../translations';
import { LnbcInvoice } from '../../types/primal';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import Modal from '../Modal/Modal';
import QrCode from '../QrCode/QrCode';
import { getDecodedToken, Token } from "@cashu/cashu-ts";
import styles from './CashuQrCodeModal.module.scss';
const CashuQrCodeModal: Component<{
id?: string,
open?: boolean,
cashu: string | undefined,
onPay?: () => void,
onClose?: () => void,
}> = (props) => {
const intl = useIntl();
const [invoice, setInvoice] = createStore<Token>({ token: []});
createEffect(() => {
if (props.cashu) {
const dec: Token = getDecodedToken(props.cashu);
setInvoice(reconcile(dec));
} else {
setInvoice(reconcile({ token: [] }));
}
});
const amount = () =>
`${invoice.token[0]?.proofs.reduce((acc, v) => acc + v.amount, 0) || 0} sats`;
const description = () => invoice.memo || '';
return (
<Modal open={props.open} onClose={props.onClose}>
<div id={props.id} class={styles.CashuQrCodeModal}>
<div class={styles.header}>
<div class={styles.title}>
{intl.formatMessage(cashuInvoice.title)}
</div>
<button class={styles.close} onClick={props.onClose}>
</button>
</div>
<div class={styles.body}>
<div class={styles.qrCode}>
<QrCode data={props.cashu || ''} type="lightning"/>
</div>
<div class={styles.description}>{description()}</div>
<div class={styles.amount}>{amount()}</div>
<div class={styles.separator}></div>
</div>
<div class={styles.footer}>
<div class={styles.mint}>
<Show when={invoice.token[0]}>
{intl.formatMessage(cashuInvoice.mint, { url: new URL(invoice.token[0]?.mint).hostname })}
</Show>
</div>
<div class={styles.payAction}>
<ButtonPrimary onClick={props.onPay}>
{intl.formatMessage(cashuInvoice.redeem)}
</ButtonPrimary>
</div>
</div>
</div>
</Modal>
);
}
export default hookForDev(CashuQrCodeModal);

View File

@ -20,6 +20,7 @@ import CustomZap from '../CustomZap/CustomZap';
import NoteContextMenu from '../Note/NoteContextMenu'; import NoteContextMenu from '../Note/NoteContextMenu';
import LnQrCodeModal from '../LnQrCodeModal/LnQrCodeModal'; import LnQrCodeModal from '../LnQrCodeModal/LnQrCodeModal';
import ConfirmModal from '../ConfirmModal/ConfirmModal'; import ConfirmModal from '../ConfirmModal/ConfirmModal';
import CashuQrCodeModal from '../CashuQrCodeModal/CashuQrCodeModal';
export const [isHome, setIsHome] = createSignal(false); export const [isHome, setIsHome] = createSignal(false);
@ -174,6 +175,13 @@ const Layout: Component = () => {
onClose={app?.lnbc?.onCancel} onClose={app?.lnbc?.onCancel}
/> />
<CashuQrCodeModal
open={app?.showCashuInvoiceModal}
cashu={app?.cashu?.invoice || ''}
onPay={app?.cashu?.onPay}
onClose={app?.cashu?.onCancel}
/>
<ConfirmModal <ConfirmModal
open={app?.showConfirmModal} open={app?.showConfirmModal}
title={app?.confirmInfo?.title} title={app?.confirmInfo?.title}

View File

@ -8,6 +8,8 @@ import {
useContext useContext
} from "solid-js"; } from "solid-js";
import { PrimalNote, PrimalUser, ZapOption } from "../types/primal"; import { PrimalNote, PrimalUser, ZapOption } from "../types/primal";
import { CashuMint } from "@cashu/cashu-ts";
export type ReactionStats = { export type ReactionStats = {
likes: number, likes: number,
@ -41,7 +43,7 @@ export type ConfirmInfo = {
onAbort?: () => void, onAbort?: () => void,
}; };
export type LnbcInfo = { export type InvoiceInfo = {
invoice: string, invoice: string,
onPay?: () => void, onPay?: () => void,
onCancel?: () => void, onCancel?: () => void,
@ -57,9 +59,12 @@ export type AppContextStore = {
showNoteContextMenu: boolean, showNoteContextMenu: boolean,
noteContextMenuInfo: NoteContextMenuInfo | undefined, noteContextMenuInfo: NoteContextMenuInfo | undefined,
showLnInvoiceModal: boolean, showLnInvoiceModal: boolean,
lnbc: LnbcInfo | undefined, lnbc: InvoiceInfo | undefined,
showCashuInvoiceModal: boolean,
cashu: InvoiceInfo | undefined,
showConfirmModal: boolean, showConfirmModal: boolean,
confirmInfo: ConfirmInfo | undefined, confirmInfo: ConfirmInfo | undefined,
cashuMints: Map<string, CashuMint>,
actions: { actions: {
openReactionModal: (noteId: string, stats: ReactionStats) => void, openReactionModal: (noteId: string, stats: ReactionStats) => void,
closeReactionModal: () => void, closeReactionModal: () => void,
@ -69,8 +74,11 @@ export type AppContextStore = {
closeContextMenu: () => void, closeContextMenu: () => void,
openLnbcModal: (lnbc: string, onPay: () => void) => void, openLnbcModal: (lnbc: string, onPay: () => void) => void,
closeLnbcModal: () => void, closeLnbcModal: () => void,
openCashuModal: (cashu: string, onPay: () => void) => void,
closeCashuModal: () => void,
openConfirmModal: (confirmInfo: ConfirmInfo) => void, openConfirmModal: (confirmInfo: ConfirmInfo) => void,
closeConfirmModal: () => void, closeConfirmModal: () => void,
getCashuMint: (url: string) => CashuMint | undefined,
}, },
} }
@ -90,8 +98,11 @@ const initialData: Omit<AppContextStore, 'actions'> = {
noteContextMenuInfo: undefined, noteContextMenuInfo: undefined,
showLnInvoiceModal: false, showLnInvoiceModal: false,
lnbc: undefined, lnbc: undefined,
showCashuInvoiceModal: false,
cashu: undefined,
showConfirmModal: false, showConfirmModal: false,
confirmInfo: undefined, confirmInfo: undefined,
cashuMints: new Map(),
}; };
export const AppContext = createContext<AppContextStore>(); export const AppContext = createContext<AppContextStore>();
@ -165,6 +176,21 @@ export const AppProvider = (props: { children: JSXElement }) => {
updateStore('lnbc', () => undefined); updateStore('lnbc', () => undefined);
}; };
const openCashuModal = (cashu: string, onPay: () => void) => {
updateStore('showCashuInvoiceModal', () => true);
updateStore('cashu', () => ({
invoice: cashu,
onPay,
onCancel: () => updateStore('showCashuInvoiceModal', () => false),
}))
};
const closeCashuModal = () => {
updateStore('showCashuInvoiceModal', () => false);
updateStore('cashu', () => undefined);
};
const openConfirmModal = (confirmInfo: ConfirmInfo) => { const openConfirmModal = (confirmInfo: ConfirmInfo) => {
updateStore('showConfirmModal', () => true); updateStore('showConfirmModal', () => true);
updateStore('confirmInfo', () => ({...confirmInfo })); updateStore('confirmInfo', () => ({...confirmInfo }));
@ -179,6 +205,15 @@ export const AppProvider = (props: { children: JSXElement }) => {
updateStore('showNoteContextMenu', () => false); updateStore('showNoteContextMenu', () => false);
}; };
const getCashuMint = (url: string) => {
const formatted = new URL(url).toString();
if (!store.cashuMints.has(formatted)) {
const mint = new CashuMint(formatted);
store.cashuMints.set(formatted, mint);
}
return store.cashuMints.get(formatted);
};
// EFFECTS -------------------------------------- // EFFECTS --------------------------------------
onMount(() => { onMount(() => {
@ -227,6 +262,9 @@ export const AppProvider = (props: { children: JSXElement }) => {
closeLnbcModal, closeLnbcModal,
openConfirmModal, openConfirmModal,
closeConfirmModal, closeConfirmModal,
openCashuModal,
closeCashuModal,
getCashuMint,
} }
}); });

View File

@ -36,6 +36,7 @@ import PageCaption from '../components/PageCaption/PageCaption';
import { useMediaContext } from '../contexts/MediaContext'; import { useMediaContext } from '../contexts/MediaContext';
import PageTitle from '../components/PageTitle/PageTitle'; import PageTitle from '../components/PageTitle/PageTitle';
import Lnbc from '../components/Lnbc/Lnbc'; import Lnbc from '../components/Lnbc/Lnbc';
import Cashu from '../components/Cashu/Cashu';
type AutoSizedTextArea = HTMLTextAreaElement & { _baseScrollHeight: number }; type AutoSizedTextArea = HTMLTextAreaElement & { _baseScrollHeight: number };
@ -840,6 +841,13 @@ const Messages: Component = () => {
return test return test
}; };
const msgHasCashu = (msg: DirectMessage) => {
const r =/(\s+|\r\n|\r|\n|^)cashuA[a-zA-Z0-9]+/;
const test = r.test(msg.content);
return test
};
createEffect(() => { createEffect(() => {
if (account?.hasPublicKey()) { if (account?.hasPublicKey()) {
profile?.actions.setProfileKey(account.publicKey) profile?.actions.setProfileKey(account.publicKey)
@ -894,7 +902,7 @@ const Messages: Component = () => {
} }
const renderMessage = (msg: DirectMessage, thread: DirectMessageThread) => { const renderMessage = (msg: DirectMessage, thread: DirectMessageThread) => {
if (!msgHasInvoice(msg)) { if (!msgHasInvoice(msg) && !msgHasCashu(msg)) {
return ( return (
<div <div
class={styles.message} class={styles.message}
@ -913,7 +921,7 @@ const Messages: Component = () => {
let sectionIndex = 0; let sectionIndex = 0;
tokens.forEach((t) => { tokens.forEach((t) => {
if (t.startsWith('lnbc')) { if (t.startsWith('lnbc') || t.startsWith('cashuA')) {
if (sections[sectionIndex]) sectionIndex++; if (sections[sectionIndex]) sectionIndex++;
sections[sectionIndex] = t; sections[sectionIndex] = t;
@ -956,6 +964,15 @@ const Messages: Component = () => {
<Lnbc lnbc={section} noBack={true} alternative={!isSelectedSender(thread.author)} /> <Lnbc lnbc={section} noBack={true} alternative={!isSelectedSender(thread.author)} />
</div> </div>
</Match> </Match>
<Match when={section.startsWith('cashuA')}>
<div
class={styles.messageLn}
data-event-id={msg.id}
title={date(msg.created_at || 0).date.toLocaleString()}
>
<Cashu token={section} noBack={true} alternative={!isSelectedSender(thread.author)} />
</div>
</Match>
</Switch> </Switch>
)} )}
</For> </For>

View File

@ -2199,3 +2199,54 @@ export const lnInvoice = {
}, },
}; };
export const cashuInvoice = {
redeem: {
id: 'cashuInvoice.redeem',
defaultMessage: 'Reedem',
description: 'Reedem ecash action',
},
pending: {
id: 'cashuInvoice.pending',
defaultMessage: 'Pending',
description: 'Pending ecash',
},
spent: {
id: 'cashuInvoice.spent',
defaultMessage: 'Spent',
description: 'Spent ecash',
},
title: {
id: 'cashuInvoice.title',
defaultMessage: 'Cashu Ecash',
description: 'Cashu Ecash title',
},
mint: {
id: 'cashuInvoice.mint',
defaultMessage: 'Mint: {url}',
description: 'Mint url',
},
confirm: {
title: {
id: 'cashuInvoice.confirm.title',
defaultMessage: 'Are you sure?',
description: 'Cashu invoice pay confirmation',
},
description: {
id: 'cashuInvoice.confirm.description',
defaultMessage: 'Redeem {amount}',
description: 'Cashu Invoice confirm description',
},
confirmLabel: {
id: 'cashuInvoice.confirm.confirmLabel',
defaultMessage: 'Yes, redeem',
description: 'Cashu Invoice confirm button label',
},
abortLabel: {
id: 'cashuInvoice.confirm.abortLabel',
defaultMessage: 'Cancel',
description: 'Cashu Invoice confirm button label',
},
},
};