Preliminary lightning invoice rendering

This commit is contained in:
Bojan Mojsilovic 2024-03-27 18:41:14 +01:00
parent 371b55aa44
commit 4e15d19b40
19 changed files with 1023 additions and 8 deletions

20
package-lock.json generated
View File

@ -18,6 +18,7 @@
"@thisbeyond/solid-select": "^0.13.0",
"@types/dompurify": "3.0.2",
"dompurify": "3.0.5",
"light-bolt11-decoder": "^3.1.1",
"medium-zoom": "1.0.8",
"nostr-tools": "1.15.0",
"photoswipe": "5.4.3",
@ -1734,6 +1735,25 @@
"node": ">=6"
}
},
"node_modules/light-bolt11-decoder": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.1.1.tgz",
"integrity": "sha512-sLg/KCwYkgsHWkefWd6KqpCHrLFWWaXTOX3cf6yD2hAzL0SLpX+lFcaFK2spkjbgzG6hhijKfORDc9WoUHwX0A==",
"dependencies": {
"@scure/base": "1.1.1"
}
},
"node_modules/light-bolt11-decoder/node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
]
},
"node_modules/lru-cache": {
"version": "5.1.1",
"dev": true,

View File

@ -30,6 +30,7 @@
"@thisbeyond/solid-select": "^0.13.0",
"@types/dompurify": "3.0.2",
"dompurify": "3.0.5",
"light-bolt11-decoder": "^3.1.1",
"medium-zoom": "1.0.8",
"nostr-tools": "1.15.0",
"photoswipe": "5.4.3",

View File

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_6181_960)">
<path d="M2 1.5H9C9.27614 1.5 9.5 1.72386 9.5 2V2.25C9.5 2.66421 9.83579 3 10.25 3C10.6642 3 11 2.66421 11 2.25V2C11 0.895431 10.1046 0 9 0H2C0.895431 0 0 0.89543 0 2V9C0 10.1046 0.895431 11 2 11H2.25C2.66421 11 3 10.6642 3 10.25C3 9.83579 2.66421 9.5 2.25 9.5H2C1.72386 9.5 1.5 9.27614 1.5 9V2C1.5 1.72386 1.72386 1.5 2 1.5Z" fill="#AAAAAA"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 7C5 5.89543 5.89543 5 7 5H14C15.1046 5 16 5.89543 16 7V14C16 15.1046 15.1046 16 14 16H7C5.89543 16 5 15.1046 5 14V7ZM7 6.5H14C14.2761 6.5 14.5 6.72386 14.5 7V14C14.5 14.2761 14.2761 14.5 14 14.5H7C6.72386 14.5 6.5 14.2761 6.5 14V7C6.5 6.72386 6.72386 6.5 7 6.5Z" fill="#AAAAAA"/>
</g>
<defs>
<clipPath id="clip0_6181_960">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 923 B

View File

@ -0,0 +1,10 @@
<svg width="14" height="20" viewBox="0 0 14 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.9211 7.71733C14.1194 7.45376 13.921 7.0876 13.5799 7.0876H8.66235L9.6442 0.953049C9.81526 0.125008 8.6907 -0.353895 8.15325 0.318065L0.0861641 11.5986C-0.123564 11.8608 0.07342 12.2377 0.420148 12.2377H5.30747L4.28387 19.0603C4.12666 19.8963 5.27231 20.3544 5.79207 19.6633L13.9211 7.71733Z" fill="url(#paint0_linear_6181_952)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.9211 7.71733C14.1194 7.45376 13.921 7.0876 13.5799 7.0876H8.66235L9.6442 0.953049C9.81526 0.125008 8.6907 -0.353895 8.15325 0.318065L0.0861641 11.5986C-0.123564 11.8608 0.07342 12.2377 0.420148 12.2377H5.30747L4.28387 19.0603C4.12666 19.8963 5.27231 20.3544 5.79207 19.6633L13.9211 7.71733Z" fill="#FA9011"/>
<defs>
<linearGradient id="paint0_linear_6181_952" x1="4.55" y1="1.37931" x2="10.0011" y2="18.3238" gradientUnits="userSpaceOnUse">
<stop offset="0.078125" stop-color="#FFD12F"/>
<stop offset="0.860784" stop-color="#FF9F2F"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -20,6 +20,8 @@ import ReactionsModal from '../ReactionsModal/ReactionsModal';
import { useAppContext } from '../../contexts/AppContext';
import CustomZap from '../CustomZap/CustomZap';
import NoteContextMenu from '../Note/NoteContextMenu';
import LnQrCodeModal from '../LnQrCodeModal/LnQrCodeModal';
import ConfirmModal from '../ConfirmModal/ConfirmModal';
export const [isHome, setIsHome] = createSignal(false);
@ -166,6 +168,23 @@ const Layout: Component = () => {
onFail={app?.customZap?.onFail}
onCancel={app?.customZap?.onCancel}
/>
<LnQrCodeModal
open={app?.showLnInvoiceModal}
lnbc={app?.lnbc?.invoice || ''}
onPay={app?.lnbc?.onPay}
onClose={app?.lnbc?.onCancel}
/>
<ConfirmModal
open={app?.showConfirmModal}
title={app?.confirmInfo?.title}
description={app?.confirmInfo?.description}
confirmLabel={app?.confirmInfo?.confirmLabel}
abortLabel={app?.confirmInfo?.abortLabel}
onConfirm={app?.confirmInfo?.onConfirm}
onAbort={app?.confirmInfo?.onAbort}
/>
</div>
</Show>
</div>

View File

@ -0,0 +1,128 @@
.LnQrCodeModal {
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;
.expiryDate {
color: var(--text-secondary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.expiredDate {
color: var(--text-tertiary);
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,88 @@
import { useIntl } from '@cookbook/solid-intl';
// @ts-ignore
import { decode } from 'light-bolt11-decoder';
import { Component, createEffect } 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 { lnInvoice } from '../../translations';
import { LnbcInvoice } from '../../types/primal';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import Modal from '../Modal/Modal';
import QrCode from '../QrCode/QrCode';
import styles from './LnQrCodeModal.module.scss';
const LnQrCodeModal: Component<{
id?: string,
open?: boolean,
lnbc: string | undefined,
onPay?: () => void,
onClose?: () => void,
}> = (props) => {
const intl = useIntl();
const [invoice, setInvoice] = createStore<LnbcInvoice>(emptyInvoice);
createEffect(() => {
if (props.lnbc) {
const dec: LnbcInvoice = decode(props.lnbc);
setInvoice(reconcile(dec));
} else {
setInvoice(reconcile(emptyInvoice));
}
});
const expiryDate = () => {
const expiry = invoice.sections.find(s => s.name === 'expiry')?.value as number;
const created = invoice.sections.find(s => s.name === 'timestamp')?.value as number;
return expiry + created;
}
const amount = () =>
`${humanizeNumber(parseInt(invoice.sections.find(s => s.name === 'amount')?.value ||'0') / 1_000)} sats`;
const description = () =>
invoice.sections.find(s => s.name === 'description')?.value;
return (
<Modal open={props.open} onClose={props.onClose}>
<div id={props.id} class={styles.LnQrCodeModal}>
<div class={styles.header}>
<div class={styles.title}>
{intl.formatMessage(lnInvoice.title)}
</div>
<button class={styles.close} onClick={props.onClose}>
</button>
</div>
<div class={styles.body}>
<div class={styles.qrCode}>
<QrCode data={props.lnbc || ''} />
</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.expiryDate}>
{intl.formatMessage(lnInvoice.expires, { date: dateFuture(expiryDate(), 'long').label })}
</div>
<div class={styles.payAction}>
<ButtonPrimary onClick={props.onPay}>
{intl.formatMessage(lnInvoice.pay)}
</ButtonPrimary>
</div>
</div>
</div>
</Modal>
);
}
export default hookForDev(LnQrCodeModal);

View File

@ -0,0 +1,161 @@
.lnbc {
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;
.expiryDate {
color: var(--text-secondary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.expiredDate {
color: var(--text-tertiary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.payAction {
height: 36px;
min-width: 120px;
button {
width: 100%;
height: 100%;
}
}
}
}
.lnIcon {
width: 20px;
height: 20px;
background-image: url('../../assets/icons/lightning.svg');
background-size: contain;
background-repeat: no-repeat;
}

View File

@ -0,0 +1,349 @@
import { Component, createEffect, createSignal, onMount, Show } from 'solid-js';
import { hookForDev } from '../../lib/devTools';
// @ts-ignore
import { decode } from 'light-bolt11-decoder';
import styles from './Lnbc.module.scss';
import { createStore, reconcile } from 'solid-js/store';
import { humanizeNumber } from '../../lib/stats';
import { date, dateFuture } from '../../lib/dates';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import ButtonGhost from '../Buttons/ButtonGhost';
import { LnbcInvoice } from '../../types/primal';
import { emptyInvoice, Kind } from '../../constants';
import { useAppContext } from '../../contexts/AppContext';
import { sendMessage, subTo } from '../../lib/sockets';
import { APP_ID } from '../../App';
import { signEvent } from '../../lib/nostrAPI';
import Loader from '../Loader/Loader';
import { logError, logInfo } from '../../lib/logger';
import { useToastContext } from '../Toaster/Toaster';
import { useIntl } from '@cookbook/solid-intl';
import { lnInvoice } from '../../translations';
const Lnbc: Component< { id?: string, lnbc: string } > = (props) => {
const app = useAppContext();
const toast = useToastContext();
const intl = useIntl();
const [invoice, setInvoice] = createStore<LnbcInvoice>({ ...emptyInvoice });
const [invoiceCopied, setInvoiceCopied] = createSignal(false);
const [paymentInProgress, setPaymentInProgress] = createSignal(false);
createEffect(() => {
const dec: LnbcInvoice = decode(props.lnbc);
setInvoice(reconcile({...emptyInvoice}))
setInvoice(() => ({ ...dec }));
});
createEffect(() => {
if (invoiceCopied()) {
setTimeout(() => {
setInvoiceCopied(() => false);
}, 1_000);
}
})
const isLightning = () => invoice.sections.find(s => s.name === 'lightning_network');
const expiryDate = () => {
const expiry = invoice.sections.find(s => s.name === 'expiry')?.value as number;
const created = invoice.sections.find(s => s.name === 'timestamp')?.value as number;
return expiry + created;
}
const hasExpired = () => {
const today = Math.floor((new Date()).getTime() / 1_000);
return today > expiryDate();
}
const amount = () =>
`${humanizeNumber(parseInt(invoice.sections.find(s => s.name === 'amount')?.value ||'0') / 1_000)} sats`;
const description = () =>
invoice.sections.find(s => s.name === 'description')?.value;
const confirmPayment = () => app?.actions.openConfirmModal({
title: intl.formatMessage(lnInvoice.confirm.title),
description: intl.formatMessage(lnInvoice.confirm.description, { amount: amount() }),
confirmLabel: intl.formatMessage(lnInvoice.confirm.confirmLabel),
abortLabel: intl.formatMessage(lnInvoice.confirm.abortLabel),
onAbort: app.actions.closeConfirmModal,
onConfirm: () => {
app.actions.closeConfirmModal();
payInvoice();
},
});
const payInvoice = () => {
setPaymentInProgress(() => true);
const walletSocket = new WebSocket('wss://wallet.primal.net/v1');
walletSocket.addEventListener('close', () => {
logInfo('PREMIUM SOCKET CLOSED');
});
walletSocket.addEventListener('open', () => {
logInfo('WALLET SOCKET OPENED');
sendPayment(walletSocket, (success: boolean) => {
if (!success) {
toast?.sendWarning(`Failed to pay ${amount()}`);
}
walletSocket.close();
setPaymentInProgress(() => false);
});
});
};
const sendPayment = async (socket: WebSocket, then?: (success: boolean) => void) => {
const subId = `sp_${APP_ID}`;
let success = true;
const unsub = subTo(socket, subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
then && then(success);
}
if (type === 'NOTICE') {
success = false;
}
});
const content = JSON.stringify(
["withdraw", {
subwallet: 1,
lnInvoice: invoice.paymentRequest,
target_lud16: '',
note_for_recipient: invoice.sections.find(s => s.name === 'description')?.value || '',
note_for_self: '',
}],
);
const event = {
content,
kind: Kind.WALLET_OPERATION,
created_at: Math.ceil((new Date()).getTime() / 1000),
tags: [],
};
try {
const signedEvent = await signEvent(event);
sendMessage(socket, JSON.stringify([
"REQ",
subId,
{cache: ["wallet", { operation_event: signedEvent }]},
]));
} catch (reason) {
logError('failed to sign due to: ', reason);
}
};
return (
<div id={props.id} class={styles.lnbc}>
<Show when={paymentInProgress()}>
<div class={styles.paymentOverlay}>
<Loader />
</div>
</Show>
<div class={styles.header}>
<Show when={isLightning()}>
<div class={styles.title}>
<div class={styles.lnIcon}></div>
<div>{intl.formatMessage(lnInvoice.title)}</div>
</div>
</Show>
<div class={styles.headerActions}>
<Show
when={!hasExpired()}
>
<ButtonGhost
onClick={(e: MouseEvent) => {
e.preventDefault();
app?.actions.openLnbcModal(props.lnbc, () => {
app.actions.closeLnbcModal();
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.lnbc);
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}>
<Show
when={!hasExpired()}
fallback={
<div class={styles.expiredDate}>
{intl.formatMessage(lnInvoice.expired, { date: date(expiryDate(), 'long').label })}
</div>
}
>
<div class={styles.expiryDate}>
{intl.formatMessage(lnInvoice.expires, { date: dateFuture(expiryDate(), 'long').label })}
</div>
<div class={styles.payAction}>
<ButtonPrimary onClick={(e: MouseEvent) => {
e.preventDefault();
confirmPayment();
}}>
{intl.formatMessage(lnInvoice.pay)}
</ButtonPrimary>
</div>
</Show>
</div>
</div>
);
}
export default hookForDev(Lnbc);
// sections = [
// {
// "name": "lightning_network",
// "letters": "ln"
// },
// {
// "name": "coin_network",
// "letters": "bc",
// "value": {
// "bech32": "bc",
// "pubKeyHash": 0,
// "scriptHash": 5,
// "validWitnessVersions": [
// 0
// ]
// }
// },
// {
// "name": "amount",
// "letters": "100u",
// "value": "10000000"
// },
// {
// "name": "separator",
// "letters": "1"
// },
// {
// "name": "timestamp",
// "letters": "pjatlyx",
// "value": 1708522630
// },
// {
// "name": "payment_secret",
// "tag": "s",
// "letters": "sp5938h8ewswdm7smn9yfge6wvfeletzxrujz2kt6yjxl77at09zlys",
// "value": "2c4f73e5d07377e86e6522519d3989cff2b1187c909565e89237fdeeade517c9"
// },
// {
// "name": "payment_hash",
// "tag": "p",
// "letters": "pp5edcuua748en26zlyjga47gpcga3htnw06xmqw4zrkwwz37s45cxq",
// "value": "cb71ce77d53e66ad0be4923b5f2038476375cdcfd1b6075443b39c28fa15a60c"
// },
// {
// "name": "description",
// "tag": "d",
// "letters": "dqgv4h82arn",
// "value": "enuts"
// },
// {
// "name": "expiry",
// "tag": "x",
// "letters": "xqzjc",
// "value": 600
// },
// {
// "name": "min_final_cltv_expiry",
// "tag": "c",
// "letters": "cqpj",
// "value": 18
// },
// {
// "name": "route_hint",
// "tag": "r",
// "letters": "rzjqgfffll4jmjf0tffqtx47xt886gzp9fajp3966xz96gm2xj9cqedxrrld5qq0tgqqqqqqqqqqqqqrssqyg",
// "value": [
// {
// "pubkey": "021294fff596e497ad2902cd5f19673e9020953d90625d68c22e91b51a45c032d3",
// "short_channel_id": "0c7f6d0007ad0000",
// "fee_base_msat": 0,
// "fee_proportional_millionths": 450,
// "cltv_expiry_delta": 34
// }
// ]
// },
// {
// "name": "feature_bits",
// "tag": "9",
// "letters": "9qxpqysgq",
// "value": {
// "option_data_loss_protect": "unsupported",
// "initial_routing_sync": "unsupported",
// "option_upfront_shutdown_script": "unsupported",
// "gossip_queries": "unsupported",
// "var_onion_optin": "required",
// "gossip_queries_ex": "unsupported",
// "option_static_remotekey": "unsupported",
// "payment_secret": "required",
// "basic_mpp": "supported",
// "option_support_large_channel": "unsupported",
// "extra_bits": {
// "start_bit": 20,
// "bits": [
// false,
// false,
// false,
// false,
// false,
// true,
// false,
// false,
// false,
// false
// ],
// "has_required": false
// }
// }
// },
// {
// "name": "signature",
// "letters": "ml5za767e9scmd52l8mh8zl0g93n74jq0asr98ezvq0gpw8cmsrknehucng4utdjm3cx5mpzkc3psty5yp3ftddkhhrp2hsvy3q08ucq",
// "value": "dfe82efb5ec9618db68af9f7738bef41633f56407f60329f22601e80b8f8dc0769e6fcc4d15e2db2dc706a6c22b622182c94206295b5b6bdc6155e0c2440f3f300"
// },
// {
// "name": "checksum",
// "letters": "ef6k3v"
// }
// ],

View File

@ -9,17 +9,14 @@ import { useIntl } from '@cookbook/solid-intl';
import { truncateNumber } from '../../../lib/notifications';
import { canUserReceiveZaps, zapNote } from '../../../lib/zap';
import CustomZap from '../../CustomZap/CustomZap';
import { useSettingsContext } from '../../../contexts/SettingsContext';
import zapMD from '../../../assets/lottie/zap_md.json';
import { toast as t } from '../../../translations';
import PrimalMenu from '../../PrimalMenu/PrimalMenu';
import { hookForDev } from '../../../lib/devTools';
import NoteContextMenu from '../NoteContextMenu';
import { getScreenCordinates } from '../../../utils';
import ZapAnimation from '../../ZapAnimation/ZapAnimation';
import ReactionsModal from '../../ReactionsModal/ReactionsModal';
import { CustomZapInfo, useAppContext } from '../../../contexts/AppContext';
const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> = (props) => {

View File

@ -7,6 +7,7 @@ import {
isHashtag,
isImage,
isInterpunction,
isLnbc,
isMixCloud,
isMp4Video,
isNoteMention,
@ -45,6 +46,7 @@ import { useIntl } from '@cookbook/solid-intl';
import { actions } from '../../translations';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import Lnbc from '../Lnbc/Lnbc';
const groupGridLimit = 7;
@ -396,6 +398,12 @@ const ParsedNote: Component<{
return;
}
if (isLnbc(token)) {
lastSignificantContent = 'lnbc';
updateContent(content, 'lnbc', token);
return;
}
lastSignificantContent = 'text';
updateContent(content, 'text', token);
return;
@ -1061,6 +1069,18 @@ const ParsedNote: Component<{
</For>
};
const renderLnbc = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
setWordsDisplayed(w => w + 100);
return <Lnbc lnbc={token} />
}}
</For>
}
const renderContent = (item: NoteContent, index: number) => {
const renderers: Record<string, (item: NoteContent, index?: number) => JSXElement> = {
@ -1081,6 +1101,7 @@ const ParsedNote: Component<{
tagmention: renderTagMention,
hashtag: renderHashtag,
emoji: renderEmoji,
lnbc: renderLnbc,
}
return renderers[item.type] ?

View File

@ -1,4 +1,4 @@
import { ContentModeration, FeedPage, } from "./types/primal";
import { ContentModeration, FeedPage, LnbcInvoice, } from "./types/primal";
import logoFire from './assets/icons/logo_fire.svg';
import logoIce from './assets/icons/logo_ice.svg';
@ -137,6 +137,8 @@ export enum Kind {
SuggestedUsersByCategory = 10_000_134,
UploadChunk = 10_000_135,
UserRelays=10_000_139,
WALLET_OPERATION = 10_000_300,
}
export const relayConnectingTimeout = 1000;
@ -253,6 +255,7 @@ export const urlRegexG = /https?:\/\/(www\.)?[-a-zA-Z0-9\u00F0-\u02AF@:%._\+~#=]
export const urlExtractRegex = /https?:\/\/\S+\.[^()]+(?:\([^)]*\))*/;
export const interpunctionRegex = /^(\.|,|;|\?|\!)$/;
export const emojiRegex = /(?:\s|^)\:\w+\:/;
export const lnRegex = /lnbc[a-zA-Z0-9]*/;
export const hashtagRegex = /(?:\s|^)#[^\s!@#$%^&*(),.?":{}|<>]+/i;
export const linebreakRegex = /(\r\n|\r|\n)/ig;
@ -386,3 +389,10 @@ export const uploadLimit = {
regular: 100,
premium: 1024,
}
export const emptyInvoice: LnbcInvoice = {
paymentRequest: '',
sections: [],
expiry: 0,
route_hints: [],
};

View File

@ -32,6 +32,21 @@ export type NoteContextMenuInfo = {
openReactions?: () => void,
};
export type ConfirmInfo = {
title: string,
description: string,
confirmLabel?: string,
abortLabel?: string,
onConfirm?: () => void,
onAbort?: () => void,
};
export type LnbcInfo = {
invoice: string,
onPay?: () => void,
onCancel?: () => void,
};
export type AppContextStore = {
isInactive: boolean,
appState: 'sleep' | 'waking' | 'woke',
@ -41,6 +56,10 @@ export type AppContextStore = {
customZap: CustomZapInfo | undefined,
showNoteContextMenu: boolean,
noteContextMenuInfo: NoteContextMenuInfo | undefined,
showLnInvoiceModal: boolean,
lnbc: LnbcInfo | undefined,
showConfirmModal: boolean,
confirmInfo: ConfirmInfo | undefined,
actions: {
openReactionModal: (noteId: string, stats: ReactionStats) => void,
closeReactionModal: () => void,
@ -48,6 +67,10 @@ export type AppContextStore = {
closeCustomZapModal: () => void,
openContextMenu: (note: PrimalNote, position: DOMRect | undefined, openCustomZapModal: () => void, openReactionModal: () => void) => void,
closeContextMenu: () => void,
openLnbcModal: (lnbc: string, onPay: () => void) => void,
closeLnbcModal: () => void,
openConfirmModal: (confirmInfo: ConfirmInfo) => void,
closeConfirmModal: () => void,
},
}
@ -65,6 +88,10 @@ const initialData: Omit<AppContextStore, 'actions'> = {
customZap: undefined,
showNoteContextMenu: false,
noteContextMenuInfo: undefined,
showLnInvoiceModal: false,
lnbc: undefined,
showConfirmModal: false,
confirmInfo: undefined,
};
export const AppContext = createContext<AppContextStore>();
@ -124,6 +151,30 @@ export const AppProvider = (props: { children: JSXElement }) => {
updateStore('showNoteContextMenu', () => true);
};
const openLnbcModal = (lnbc: string, onPay: () => void) => {
updateStore('showLnInvoiceModal', () => true);
updateStore('lnbc', () => ({
invoice: lnbc,
onPay,
onCancel: () => updateStore('showLnInvoiceModal', () => false),
}))
};
const closeLnbcModal = () => {
updateStore('showLnInvoiceModal', () => false);
updateStore('lnbc', () => undefined);
};
const openConfirmModal = (confirmInfo: ConfirmInfo) => {
updateStore('showConfirmModal', () => true);
updateStore('confirmInfo', () => ({...confirmInfo }));
};
const closeConfirmModal = () => {
updateStore('showConfirmModal', () => false);
updateStore('confirmInfo', () => undefined);
};
const closeContextMenu = () => {
updateStore('showNoteContextMenu', () => false);
};
@ -172,6 +223,10 @@ export const AppProvider = (props: { children: JSXElement }) => {
closeCustomZapModal,
openContextMenu,
closeContextMenu,
openLnbcModal,
closeLnbcModal,
openConfirmModal,
closeConfirmModal,
}
});

View File

@ -65,3 +65,51 @@ export const date = (postTimestamp: number, style: Intl.RelativeTimeFormatStyle
return { date, label: `${diff}s` };
};
export const dateFuture = (postTimestamp: number, style: Intl.RelativeTimeFormatStyle = 'short', since?: number) => {
const today = since ?? Math.floor((new Date()).getTime() / 1000);
const date = new Date(postTimestamp * 1000);
const minute = 60;
const hour = minute * 60;
const day = hour * 24;
const week = day * 7;
const month = day * 30;
const year = month * 12;
const rtf = new Intl.RelativeTimeFormat('en', { style });
const diff = postTimestamp - today;
if ( diff > year) {
const years = Math.floor(diff / year);
return { date, label: rtf.format(-years, 'years').replace(' ago', '') };
}
if (diff > month) {
const months = Math.floor(diff / month);
return { date, label: rtf.format(-months, 'months').replace(' ago', '') };
}
if (diff > week) {
const weeks = Math.floor(diff / week);
return { date, label: rtf.format(-weeks, 'weeks').replace(' ago', '') };
}
if (diff > day) {
const days = Math.floor(diff / day);
return { date, label: rtf.format(-days, 'days').replace(' ago', '') };
}
if (diff > hour) {
const hours = Math.floor(diff / hour);
return { date, label: rtf.format(-hours, 'hours').replace(' ago', '') };
}
if (diff > minute) {
const minutes = Math.floor(diff / minute);
return { date, label: rtf.format(-minutes, 'minutes').replace(' ago', '') };
}
return { date, label: `${diff}s` };
};

View File

@ -3,7 +3,7 @@ import { A } from "@solidjs/router";
import { Relay } from "nostr-tools";
import { createStore } from "solid-js/store";
import LinkPreview from "../components/LinkPreview/LinkPreview";
import { appleMusicRegex, emojiRegex, hashtagRegex, interpunctionRegex, Kind, linebreakRegex, mixCloudRegex, nostrNestsRegex, noteRegex, noteRegexLocal, profileRegex, profileRegexG, soundCloudRegex, spotifyRegex, tagMentionRegex, twitchRegex, urlRegex, urlRegexG, wavlakeRegex, youtubeRegex } from "../constants";
import { appleMusicRegex, emojiRegex, hashtagRegex, interpunctionRegex, Kind, linebreakRegex, lnRegex, mixCloudRegex, nostrNestsRegex, noteRegex, noteRegexLocal, profileRegex, profileRegexG, soundCloudRegex, spotifyRegex, tagMentionRegex, twitchRegex, urlRegex, urlRegexG, wavlakeRegex, youtubeRegex } from "../constants";
import { sendMessage, subscribeTo } from "../sockets";
import { MediaSize, NostrRelays, NostrRelaySignedEvent, PrimalNote, SendNoteResult } from "../types/primal";
import { logError, logInfo, logWarning } from "./logger";
@ -60,6 +60,7 @@ export const isNoteMention = (url: string) => noteRegexLocal.test(url);
export const isUserMention = (url: string) => profileRegex.test(url);
export const isInterpunction = (url: string) => interpunctionRegex.test(url);
export const isCustomEmoji = (url: string) => emojiRegex.test(url);
export const isLnbc = (url: string) => lnRegex.test(url);
export const isImage = (url: string) => ['.jpg', '.jpeg', '.webp', '.png', '.gif', '.format=png'].some(x => url.includes(x));
export const isMp4Video = (url: string) => ['.mp4', '.mov'].some(x => url.includes(x));

23
src/lib/sockets.ts Normal file
View File

@ -0,0 +1,23 @@
import { NostrEventType, NostrEventContent, NostrEvent, NostrEOSE } from "../types/primal";
export const subTo = (socket: WebSocket, subId: string, cb: (type: NostrEventType, subId: string, content?: NostrEventContent) => void ) => {
const listener = (event: MessageEvent) => {
const message: NostrEvent | NostrEOSE = JSON.parse(event.data);
const [type, subscriptionId, content] = message;
if (subId === subscriptionId) {
cb(type, subscriptionId, content);
}
};
socket.addEventListener('message', listener);
return () => {
socket.removeEventListener('message', listener);
};
};
export const sendMessage = (socket: WebSocket, message: string) => {
socket.readyState === WebSocket.OPEN && socket.send(message);
}

View File

@ -2083,3 +2083,49 @@ export const followWarning = {
description: 'Abort forgot pin action',
},
};
export const lnInvoice = {
pay: {
id: 'lnInvoice.pay',
defaultMessage: 'Pay',
description: 'Pay invoice action',
},
title: {
id: 'lnInvoice.title',
defaultMessage: 'Lightning Invoice',
description: 'Lightning Invoice title',
},
expired: {
id: 'lnInvoice.expired',
defaultMessage: 'Expired: {date} ago',
description: 'Expired time',
},
expires: {
id: 'lnInvoice.expires',
defaultMessage: 'Expires: in {date}',
description: 'Expires time',
},
confirm: {
title: {
id: 'lnInvoice.confirm.title',
defaultMessage: 'Are you sure?',
description: 'Lightning invoice pay confirmation',
},
description: {
id: 'lnInvoice.confirm.description',
defaultMessage: 'Pay {amount}',
description: 'Lightning Invoice confirm description',
},
confirmLabel: {
id: 'lnInvoice.confirm.confirmLabel',
defaultMessage: 'Yes, pay',
description: 'Lightning Invoice confirm button label',
},
abortLabel: {
id: 'lnInvoice.confirm.abortLabel',
defaultMessage: 'Cancel',
description: 'Lightning Invoice confirm button label',
},
},
};

27
src/types/primal.d.ts vendored
View File

@ -676,3 +676,30 @@ export type MembershipStatus = {
used_storage?: number,
expires_on?: number,
};
export type LncbSectionNetwork = {
name: 'lightning_network',
letters: 'ln',
};
export type LnbcSection = {
name: string,
letters: string,
tag?: string,
value?: any
};
export type LnbcRouteHint = {
pubkey: string,
short_channel_id: string,
fee_base_msat: number,
fee_proportional_millionths: number,
cltv_expiry_delta: number,
}
export type LnbcInvoice = {
paymentRequest: string,
sections: LnbcSection[],
expiry: number,
route_hints: LnbcRouteHint[],
};