diff --git a/package-lock.json b/package-lock.json index e422c5c..cc95b98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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, diff --git a/package.json b/package.json index d2aa588..fc715d2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/assets/icons/copy_border.svg b/src/assets/icons/copy_border.svg new file mode 100644 index 0000000..b1fe018 --- /dev/null +++ b/src/assets/icons/copy_border.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/lightning.svg b/src/assets/icons/lightning.svg new file mode 100644 index 0000000..48fb8ed --- /dev/null +++ b/src/assets/icons/lightning.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index 09f97b4..a6ffb67 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -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} /> + + + + diff --git a/src/components/LnQrCodeModal/LnQrCodeModal.module.scss b/src/components/LnQrCodeModal/LnQrCodeModal.module.scss new file mode 100644 index 0000000..7fcd849 --- /dev/null +++ b/src/components/LnQrCodeModal/LnQrCodeModal.module.scss @@ -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; +} diff --git a/src/components/LnQrCodeModal/LnQrCodeModal.tsx b/src/components/LnQrCodeModal/LnQrCodeModal.tsx new file mode 100644 index 0000000..703d479 --- /dev/null +++ b/src/components/LnQrCodeModal/LnQrCodeModal.tsx @@ -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(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 ( + +
+
+
+ {intl.formatMessage(lnInvoice.title)} +
+ +
+ +
+
+ +
+ +
{description()}
+
{amount()}
+ +
+
+ +
+
+ {intl.formatMessage(lnInvoice.expires, { date: dateFuture(expiryDate(), 'long').label })} +
+
+ + {intl.formatMessage(lnInvoice.pay)} + +
+
+
+
+ ); +} + +export default hookForDev(LnQrCodeModal); diff --git a/src/components/Lnbc/Lnbc.module.scss b/src/components/Lnbc/Lnbc.module.scss new file mode 100644 index 0000000..0fd0588 --- /dev/null +++ b/src/components/Lnbc/Lnbc.module.scss @@ -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; +} diff --git a/src/components/Lnbc/Lnbc.tsx b/src/components/Lnbc/Lnbc.tsx new file mode 100644 index 0000000..05ba4ae --- /dev/null +++ b/src/components/Lnbc/Lnbc.tsx @@ -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({ ...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 ( +
+ +
+ +
+
+
+ +
+
+
{intl.formatMessage(lnInvoice.title)}
+
+
+
+ + { + e.preventDefault(); + app?.actions.openLnbcModal(props.lnbc, () => { + app.actions.closeLnbcModal(); + confirmPayment(); + }); + }} + shrink={true} + > +
+
+
+ +
} + > + { + e.preventDefault() + navigator.clipboard.writeText(props.lnbc); + setInvoiceCopied(() => true); + }} + shrink={true} + > +
+
+ +
+
+
+
{description()}
+
{amount()}
+
+ +
+ + {intl.formatMessage(lnInvoice.expired, { date: date(expiryDate(), 'long').label })} +
+ } + > +
+ {intl.formatMessage(lnInvoice.expires, { date: dateFuture(expiryDate(), 'long').label })} +
+
+ { + e.preventDefault(); + confirmPayment(); + }}> + {intl.formatMessage(lnInvoice.pay)} + +
+ + + + ); +} + +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" +// } +// ], diff --git a/src/components/Note/NoteFooter/NoteFooter.tsx b/src/components/Note/NoteFooter/NoteFooter.tsx index c19ce42..19890f5 100644 --- a/src/components/Note/NoteFooter/NoteFooter.tsx +++ b/src/components/Note/NoteFooter/NoteFooter.tsx @@ -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) => { diff --git a/src/components/ParsedNote/ParsedNote.tsx b/src/components/ParsedNote/ParsedNote.tsx index e391653..b89e17d 100644 --- a/src/components/ParsedNote/ParsedNote.tsx +++ b/src/components/ParsedNote/ParsedNote.tsx @@ -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<{ }; + const renderLnbc = (item: NoteContent) => { + return + {(token) => { + if (isNoteTooLong()) return; + + setWordsDisplayed(w => w + 100); + + return + }} + + } + const renderContent = (item: NoteContent, index: number) => { const renderers: Record JSXElement> = { @@ -1081,6 +1101,7 @@ const ParsedNote: Component<{ tagmention: renderTagMention, hashtag: renderHashtag, emoji: renderEmoji, + lnbc: renderLnbc, } return renderers[item.type] ? diff --git a/src/components/ProfileTabs/ProfileTabs.tsx b/src/components/ProfileTabs/ProfileTabs.tsx index 4bcd55f..b577c86 100644 --- a/src/components/ProfileTabs/ProfileTabs.tsx +++ b/src/components/ProfileTabs/ProfileTabs.tsx @@ -380,9 +380,9 @@ const ProfileTabs: Component<{ - - +
+ +
} > ]+/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: [], +}; diff --git a/src/contexts/AppContext.tsx b/src/contexts/AppContext.tsx index a99b28e..d0cea30 100644 --- a/src/contexts/AppContext.tsx +++ b/src/contexts/AppContext.tsx @@ -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 = { customZap: undefined, showNoteContextMenu: false, noteContextMenuInfo: undefined, + showLnInvoiceModal: false, + lnbc: undefined, + showConfirmModal: false, + confirmInfo: undefined, }; export const AppContext = createContext(); @@ -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, } }); diff --git a/src/lib/dates.ts b/src/lib/dates.ts index 63b48aa..49ba746 100644 --- a/src/lib/dates.ts +++ b/src/lib/dates.ts @@ -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` }; +}; diff --git a/src/lib/notes.tsx b/src/lib/notes.tsx index 2e80827..26c4a1e 100644 --- a/src/lib/notes.tsx +++ b/src/lib/notes.tsx @@ -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)); diff --git a/src/lib/sockets.ts b/src/lib/sockets.ts new file mode 100644 index 0000000..15c0034 --- /dev/null +++ b/src/lib/sockets.ts @@ -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); +} diff --git a/src/translations.ts b/src/translations.ts index 6136527..acc3830 100644 --- a/src/translations.ts +++ b/src/translations.ts @@ -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', + }, + }, + +}; diff --git a/src/types/primal.d.ts b/src/types/primal.d.ts index e12d31f..a959a24 100644 --- a/src/types/primal.d.ts +++ b/src/types/primal.d.ts @@ -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[], +};