({ ...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 (
+
+
+
{description()}
+
{amount()}
+
+
+
+ }
+ >
+
+ {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[],
+};