feat: send zap

This commit is contained in:
Shusui MOYATANI
2024-01-13 12:18:16 +09:00
parent 351111815d
commit 3d4eba023d
41 changed files with 3672 additions and 454 deletions

2488
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/jsdom": "^21.1.6", "@types/jsdom": "^21.1.6",
"@types/qrcode": "^1.5.5",
"@typescript-eslint/eslint-plugin": "^6.15.0", "@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0", "@typescript-eslint/parser": "^6.15.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
@@ -46,6 +47,7 @@
"prettier": "^3.1.1", "prettier": "^3.1.1",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^4.4.11", "vite": "^4.4.11",
"vite-plugin-node-polyfills": "^0.19.0",
"vite-plugin-solid": "^2.8.0", "vite-plugin-solid": "^2.8.0",
"vite-plugin-solid-svg": "^0.7.0", "vite-plugin-solid-svg": "^0.7.0",
"vitest": "^1.1.0" "vitest": "^1.1.0"
@@ -64,6 +66,7 @@
"@types/lodash": "^4.14.202", "@types/lodash": "^4.14.202",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"bech32": "^2.0.0", "bech32": "^2.0.0",
"bolt11": "^1.4.1",
"emoji-mart": "^5.5.2", "emoji-mart": "^5.5.2",
"heroicons": "^2.1.1", "heroicons": "^2.1.1",
"i18next": "^23.7.11", "i18next": "^23.7.11",
@@ -71,9 +74,11 @@
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"nostr-tools": "^2.0.3", "nostr-tools": "^2.0.3",
"qrcode": "^1.5.3",
"solid-js": "^1.8.7", "solid-js": "^1.8.7",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"ua-parser-js": "^1.0.37", "ua-parser-js": "^1.0.37",
"webln": "^0.3.2",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"lint-staged": { "lint-staged": {

View File

@@ -12,6 +12,8 @@ const acceptableLicenses = [
'BSD-3-Clause', 'BSD-3-Clause',
'CC-BY-4.0', 'CC-BY-4.0',
'Unlicense', 'Unlicense',
// sha.js for polyfill
'(MIT AND BSD-3-Clause)',
]; ];
const asyncLicenseChecker = (options) => { const asyncLicenseChecker = (options) => {

View File

@@ -1,10 +1,10 @@
import { import {
type JSX, type JSX,
type Component, type Component,
lazy,
Switch, Switch,
Match, Match,
Show, Show,
lazy,
createSignal, createSignal,
createMemo, createMemo,
For, For,
@@ -12,6 +12,7 @@ import {
import { createMutation } from '@tanstack/solid-query'; import { createMutation } from '@tanstack/solid-query';
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg'; import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
import Bolt from 'heroicons/24/outline/bolt.svg';
import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg'; import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg';
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg'; import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
import HeartOutlined from 'heroicons/24/outline/heart.svg'; import HeartOutlined from 'heroicons/24/outline/heart.svg';
@@ -39,6 +40,8 @@ import timeout from '@/utils/timeout';
const EventDebugModal = lazy(() => import('@/components/modal/EventDebugModal')); const EventDebugModal = lazy(() => import('@/components/modal/EventDebugModal'));
const UserList = lazy(() => import('@/components/modal/UserList')); const UserList = lazy(() => import('@/components/modal/UserList'));
// eslint-disable-next-line import/no-cycle
const ZapRequestModal = lazy(() => import('@/components/modal/ZapRequestModal'));
export type ActionProps = { export type ActionProps = {
event: NostrEvent; event: NostrEvent;
@@ -339,7 +342,9 @@ const Actions: Component<ActionProps> = (props) => {
const pubkey = usePubkey(); const pubkey = usePubkey();
const commands = useCommands(); const commands = useCommands();
const [modal, setModal] = createSignal<'EventDebugModal' | 'Reactions' | 'Reposts' | null>(null); const [modal, setModal] = createSignal<
'EventDebugModal' | 'Reactions' | 'Reposts' | 'ZapRequest' | null
>(null);
const closeModal = () => setModal(null); const closeModal = () => setModal(null);
@@ -415,7 +420,7 @@ const Actions: Component<ActionProps> = (props) => {
return ( return (
<> <>
<EmojiReactions event={props.event} /> <EmojiReactions event={props.event} />
<div class="actions flex w-52 items-center justify-between gap-8 pt-1"> <div class="actions flex w-64 max-w-full items-center justify-between pt-1">
<button <button
class="shrink-0 text-fg-tertiary hover:text-fg-tertiary/70" class="shrink-0 text-fg-tertiary hover:text-fg-tertiary/70"
onClick={(ev) => { onClick={(ev) => {
@@ -429,6 +434,11 @@ const Actions: Component<ActionProps> = (props) => {
</button> </button>
<RepostAction event={props.event} /> <RepostAction event={props.event} />
<ReactionAction event={props.event} /> <ReactionAction event={props.event} />
<button type="button" onClick={() => setModal('ZapRequest')}>
<span class="flex h-4 w-4 text-fg-tertiary hover:text-r-zap">
<Bolt />
</span>
</button>
<button <button
ref={otherActionsPopup.targetRef} ref={otherActionsPopup.targetRef}
type="button" type="button"
@@ -449,6 +459,9 @@ const Actions: Component<ActionProps> = (props) => {
<Match when={modal() === 'Reposts'}> <Match when={modal() === 'Reposts'}>
<RepostsModal event={props.event} onClose={closeModal} /> <RepostsModal event={props.event} onClose={closeModal} />
</Match> </Match>
<Match when={modal() === 'ZapRequest'}>
<ZapRequestModal event={props.event} onClose={closeModal} />
</Match>
</Switch> </Switch>
</> </>
); );

View File

@@ -14,9 +14,10 @@ import UserNameDisplay from '@/components/UserDisplayName';
import useConfig from '@/core/useConfig'; import useConfig from '@/core/useConfig';
import useEmojiComplete from '@/hooks/useEmojiComplete'; import useEmojiComplete from '@/hooks/useEmojiComplete';
import { useTranslation } from '@/i18n/useTranslation'; import { useTranslation } from '@/i18n/useTranslation';
import { type CreateTextNoteParams } from '@/nostr/builder/createTextNote';
import { textNote } from '@/nostr/event'; import { textNote } from '@/nostr/event';
import parseTextNote, { ParsedTextNote } from '@/nostr/parseTextNote'; import parseTextNote, { ParsedTextNote } from '@/nostr/parseTextNote';
import useCommands, { PublishTextNoteParams } from '@/nostr/useCommands'; import useCommands from '@/nostr/useCommands';
import usePubkey from '@/nostr/usePubkey'; import usePubkey from '@/nostr/usePubkey';
import { uploadFiles, uploadNostrBuild } from '@/utils/imageUpload'; import { uploadFiles, uploadNostrBuild } from '@/utils/imageUpload';
// import usePersistStatus from '@/hooks/usePersistStatus'; // import usePersistStatus from '@/hooks/usePersistStatus';
@@ -221,7 +222,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
setLastUsedHashTags(hashtags); setLastUsedHashTags(hashtags);
let textNoteParams: PublishTextNoteParams = { let textNoteParams: CreateTextNoteParams & { relayUrls: string[] } = {
relayUrls: config().relayUrls, relayUrls: config().relayUrls,
pubkey, pubkey,
content: formattedContent, content: formattedContent,

View File

@@ -2,6 +2,7 @@ import { Show, For, createSignal, createMemo, type Component } from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools/pure'; import { type Event as NostrEvent } from 'nostr-tools/pure';
// eslint-disable-next-line import/no-cycle
import Actions from '@/components/Actions'; import Actions from '@/components/Actions';
// eslint-disable-next-line import/no-cycle // eslint-disable-next-line import/no-cycle
import EventDisplayById from '@/components/event/EventDisplayById'; import EventDisplayById from '@/components/event/EventDisplayById';

View File

@@ -8,10 +8,11 @@ import UserDisplayName from '@/components/UserDisplayName';
import useConfig from '@/core/useConfig'; import useConfig from '@/core/useConfig';
import useModalState from '@/hooks/useModalState'; import useModalState from '@/hooks/useModalState';
import { useTranslation } from '@/i18n/useTranslation'; import { useTranslation } from '@/i18n/useTranslation';
import { zapReceipt } from '@/nostr/event'; import ZapReceipt from '@/nostr/event/ZapReceipt';
import useLnurlEndpoint from '@/nostr/useLnurlEndpoint'; import useLnurlEndpoint from '@/nostr/useLnurlEndpoint';
import useProfile from '@/nostr/useProfile'; import useProfile from '@/nostr/useProfile';
import { getLnurlPayUrlFromLud06, getLnurlPayUrlFromLud16 } from '@/nostr/zap'; import lud06ToLnurlPayUrl from '@/nostr/zap/lud06ToLnurlPayUrl';
import lud16ToLnurlPayUrl from '@/nostr/zap/lud16ToLnurlPayUrl';
import ensureNonNull from '@/utils/ensureNonNull'; import ensureNonNull from '@/utils/ensureNonNull';
import { formatSiPrefix } from '@/utils/siPrefix'; import { formatSiPrefix } from '@/utils/siPrefix';
@@ -19,12 +20,12 @@ export type ZapReceiptProps = {
event: NostrEvent; event: NostrEvent;
}; };
const ZapReceipt: Component<ZapReceiptProps> = (props) => { const ZapReceiptDisplay: Component<ZapReceiptProps> = (props) => {
const i18n = useTranslation(); const i18n = useTranslation();
const { shouldMuteEvent } = useConfig(); const { shouldMuteEvent } = useConfig();
const { showProfile } = useModalState(); const { showProfile } = useModalState();
const event = createMemo(() => zapReceipt(props.event)); const event = createMemo(() => new ZapReceipt(props.event));
const { profile: senderProfile } = useProfile(() => ({ const { profile: senderProfile } = useProfile(() => ({
pubkey: event().senderPubkey(), pubkey: event().senderPubkey(),
@@ -37,13 +38,13 @@ const ZapReceipt: Component<ZapReceiptProps> = (props) => {
const lnurlPayUrlLud06 = () => { const lnurlPayUrlLud06 = () => {
const lud06 = recipientProfile()?.lud06; const lud06 = recipientProfile()?.lud06;
if (lud06 == null) return null; if (lud06 == null) return null;
return getLnurlPayUrlFromLud06(lud06); return lud06ToLnurlPayUrl(lud06);
}; };
const lnurlPayUrlLud16 = () => { const lnurlPayUrlLud16 = () => {
const lud16 = recipientProfile()?.lud16; const lud16 = recipientProfile()?.lud16;
if (lud16 == null) return null; if (lud16 == null) return null;
return getLnurlPayUrlFromLud16(lud16); return lud16ToLnurlPayUrl(lud16);
}; };
const lnurlEndpointLud06 = useLnurlEndpoint(() => const lnurlEndpointLud06 = useLnurlEndpoint(() =>
@@ -58,6 +59,12 @@ const ZapReceipt: Component<ZapReceiptProps> = (props) => {
})), })),
); );
const amountSi = () => {
const amountSats = event().amountSats();
if (amountSats == null) return null;
return formatSiPrefix(amountSats);
};
const isZapReceiptVerified = () => const isZapReceiptVerified = () =>
lnurlEndpointLud06.isZapReceiptVerified(props.event) || lnurlEndpointLud06.isZapReceiptVerified(props.event) ||
lnurlEndpointLud16.isZapReceiptVerified(props.event); lnurlEndpointLud16.isZapReceiptVerified(props.event);
@@ -69,7 +76,7 @@ const ZapReceipt: Component<ZapReceiptProps> = (props) => {
<div class="h-4 w-4 shrink-0 text-amber-500" aria-hidden="true"> <div class="h-4 w-4 shrink-0 text-amber-500" aria-hidden="true">
<Bolt /> <Bolt />
</div> </div>
<div class="mt-[-2px] shrink-0 text-xs">{formatSiPrefix(event().amountSats())}</div> <div class="mt-[-2px] shrink-0 text-xs">{amountSi()}</div>
</div> </div>
<div class="notification-user flex gap-1 overflow-hidden"> <div class="notification-user flex gap-1 overflow-hidden">
<div class="author-icon h-5 w-5 shrink-0 overflow-hidden rounded"> <div class="author-icon h-5 w-5 shrink-0 overflow-hidden rounded">
@@ -107,4 +114,4 @@ const ZapReceipt: Component<ZapReceiptProps> = (props) => {
); );
}; };
export default ZapReceipt; export default ZapReceiptDisplay;

View File

@@ -0,0 +1,380 @@
import {
createSignal,
createEffect,
batch,
onMount,
Show,
Switch,
Match,
type Component,
type JSX,
} from 'solid-js';
import { createMutation } from '@tanstack/solid-query';
import Bolt from 'heroicons/24/outline/bolt.svg';
import { type Event as NostrEvent } from 'nostr-tools/pure';
import qrcode from 'qrcode';
import { requestProvider, type WebLNProvider } from 'webln';
// eslint-disable-next-line import/no-cycle
import EventDisplay from '@/components/event/EventDisplay';
import BasicModal from '@/components/modal/BasicModal';
import useConfig from '@/core/useConfig';
import { useTranslation } from '@/i18n/useTranslation';
import createZapRequest from '@/nostr/builder/createZapRequest';
import { genericEvent } from '@/nostr/event';
import { signEvent } from '@/nostr/useCommands';
import useLnurlEndpoint from '@/nostr/useLnurlEndpoint';
import useProfile from '@/nostr/useProfile';
import usePubkey from '@/nostr/usePubkey';
import fetchLnurlCallback, { type FetchLnurlCallbackParams } from '@/nostr/zap/fetchLnurlCallback';
import lud06ToLnurlPayUrl from '@/nostr/zap/lud06ToLnurlPayUrl';
import lud16ToLnurlPayUrl from '@/nostr/zap/lud16ToLnurlPayUrl';
import verifyInvoice from '@/nostr/zap/verifyInvoice';
import ensureNonNull from '@/utils/ensureNonNull';
export type ZapRequestModalProps = {
event: NostrEvent;
// TODO zap to profile
onClose: () => void;
};
type ZapDialogProps = {
lnurlPayUrl?: string | null;
event: NostrEvent;
};
const useWebLN = () => {
const [provider, setProvider] = createSignal<WebLNProvider | undefined>();
const [status, setStatus] = createSignal<'available' | 'unavailable' | 'checking'>('checking');
onMount(() => {
requestProvider()
.then((webln) => {
batch(() => {
setProvider(webln);
setStatus('available');
});
})
.catch((err) => {
console.warn('failed to request provider', err);
setStatus('unavailable');
});
});
return { provider, status };
};
const QRCodeDisplay: Component<{ text: string }> = (props) => {
let canvasRef: HTMLCanvasElement | undefined;
const { getColorTheme } = useConfig();
createEffect(() => {
if (canvasRef == null) return;
const currentTheme = getColorTheme();
qrcode
.toCanvas(canvasRef, props.text, {
margin: 8,
color: {
dark: currentTheme.brightness === 'dark' ? '#ffffffff' : '#000000ff',
light: '#00000000',
},
})
.catch((e) => {
console.error(e);
});
});
return <canvas width="256" height="256" ref={canvasRef} />;
};
const InvoiceDisplay: Component<{ invoice: string }> = (props) => {
const i18n = useTranslation();
const webln = useWebLN();
const lightingInvoice = () => `lightning:${props.invoice}`;
const handleClickWebLN = () => {
const provider = webln.provider();
if (provider == null) return;
provider
.sendPayment(props.invoice)
.then(() => {
window.alert('success');
})
.catch((err) => {
const message = err instanceof Error ? `:${err.message}` : '';
window.alert(`failed to send zap: ${message}`);
});
};
return (
<div class="flex flex-col items-center gap-2">
<div>
<QRCodeDisplay text={lightingInvoice()} />
</div>
<a
class="inline-block rounded bg-primary p-4 font-bold text-primary-fg hover:bg-primary-hover"
href={lightingInvoice()}
>
{i18n()('zap.sendViaWallet')}
</a>
<Show when={webln.status() === 'available'}>
<button
type="button"
class="inline-block rounded bg-primary p-4 font-bold text-primary-fg hover:bg-primary-hover"
onClick={handleClickWebLN}
>
{i18n()('zap.sendViaWebLN')}
</button>
</Show>
</div>
);
};
const ZapDialog: Component<ZapDialogProps> = (props) => {
const i18n = useTranslation();
const pubkey = usePubkey();
const { config } = useConfig();
const [amountSats, setAmountSats] = createSignal<number>(1);
const [comment, setComment] = createSignal<string>('');
const { endpoint, error, allowsNostr, commentAllowed, query } = useLnurlEndpoint(() =>
ensureNonNull([props.lnurlPayUrl])(([url]) => ({
lnurlPayUrl: url,
})),
);
const event = () => genericEvent(props.event);
const hasZapTag = () => event().findTagsByName('zap').length > 0;
const lnurlPayUrlDomain = () => {
if (props.lnurlPayUrl == null) return null;
const url = new URL(props.lnurlPayUrl);
return url.host;
};
const lnurlServiceIcon = () =>
endpoint()?.decodedMetadata?.imageJPEG || endpoint()?.decodedMetadata?.imagePNG;
const minSendableInSats = () => Math.ceil((endpoint()?.minSendable ?? 1) / 1000);
const maxSendableInSats = () => Math.floor((endpoint()?.maxSendable ?? 1) / 1000);
const getInvoice = async (): Promise<string | undefined> => {
const p = pubkey();
if (p == null) return undefined;
const endpointData = endpoint();
if (endpointData == null) return undefined;
if (props.lnurlPayUrl == null) return undefined;
if (amountSats() < minSendableInSats() || amountSats() > maxSendableInSats()) return undefined;
const amountMilliSats = Math.floor(amountSats() * 1000).toString();
const { callback } = endpointData;
const callbackParams: FetchLnurlCallbackParams = {
lnurlPayUrl: props.lnurlPayUrl,
callback,
amountMilliSats,
};
if (commentAllowed() > 0 && comment().length > 0 && comment().length <= commentAllowed()) {
callbackParams.comment = comment();
}
if (allowsNostr()) {
const unsignedEvent = createZapRequest({
amountMilliSats,
content: comment(),
pubkey: p,
recipientPubkey: props.event.pubkey,
eventId: props.event.id,
relays: config().relayUrls,
lnurlPayUrl: props.lnurlPayUrl,
});
const zapRequest = await signEvent(unsignedEvent);
callbackParams.zapRequest = zapRequest;
}
const callbackResponse = await fetchLnurlCallback(callbackParams);
if (!('pr' in callbackResponse)) {
throw new Error('failed to get invoice');
}
const invoice = callbackResponse.pr;
console.log(callbackResponse, invoice);
if (
!(await verifyInvoice(invoice, {
amountMilliSats,
metadata: endpointData.metadata,
zapRequest: callbackParams.zapRequest,
}))
) {
throw new Error('Invalid invoice');
}
return invoice;
};
const getInvoiceMutation = createMutation(() => ({
mutationKey: ['getInvoiceMutation', props.event.id],
mutationFn: () => getInvoice(),
}));
const handleSubmit: JSX.EventHandler<HTMLFormElement, Event> = (ev) => {
ev.preventDefault();
getInvoiceMutation.mutate();
};
return (
<Switch>
<Match when={query.isError}>
{i18n()('zap.fetchingLnUrlEndpointError')}: {query?.error?.message}
</Match>
<Match when={error()} keyed>
{(err) => (
<>
{i18n()('zap.lnUrlEndpointError')}: {err.reason}
</>
)}
</Match>
<Match when={query.isFetching}>{i18n()('zap.fetchingLnUrlEndpoint')}</Match>
<Match when={getInvoiceMutation.isPending}>{i18n()('zap.fetchingLnUrlInvoice')}</Match>
<Match when={getInvoiceMutation.isError}>
{i18n()('zap.fetchingLnUrlInvoiceError')}: {getInvoiceMutation?.error?.message}
</Match>
<Match when={getInvoiceMutation.isSuccess && getInvoiceMutation.data} keyed>
{(invoice) => <InvoiceDisplay invoice={invoice} />}
</Match>
<Match when={query.isSuccess}>
<div class="flex flex-col items-center">
<Show when={!allowsNostr()}>
<div class="pb-8 text-center">{i18n()('zap.lnurlServiceDoesNotAllowNostr')}</div>
</Show>
<Show when={hasZapTag()}>
<div class="pb-8 text-center">{i18n()('zap.zapSplitIsNotSupported')}</div>
</Show>
<div class="flex flex-col items-center overflow-hidden rounded px-8 py-2 text-fg-secondary">
<Show when={lnurlServiceIcon()} keyed>
{(url) => (
<img
class="max-h-64 w-64 rounded object-cover"
alt="LNURL service icon"
src={url}
/>
)}
</Show>
<div class="font-bold">{lnurlPayUrlDomain()}</div>
<div>{endpoint()?.decodedMetadata?.textPlain}</div>
<div>{endpoint()?.decodedMetadata?.textLongDesc}</div>
</div>
<div class="w-96 rounded-lg border border-border p-2">
<EventDisplay event={props.event} actions={false} embedding={false} />
</div>
<form class="mt-4 flex w-64 flex-col items-center gap-1" onSubmit={handleSubmit}>
<label class="flex w-full items-center gap-2">
<input
type="number"
name="amountSats"
class="min-w-0 flex-1 rounded-md border border-border bg-bg text-center text-3xl ring-border placeholder:text-fg-secondary focus:border-border focus:ring-primary"
min={minSendableInSats()}
max={maxSendableInSats()}
required
disabled={query.isPending}
value={amountSats()}
onChange={(ev) => setAmountSats(parseInt(ev.target.value, 10))}
/>
<div class="text-center text-xl text-fg-secondary">sats</div>
</label>
<input
type="text"
name="comment"
class="w-full rounded-md border border-border bg-bg ring-border placeholder:text-fg-secondary focus:border-border focus:ring-primary"
maxLength={commentAllowed() > 0 ? commentAllowed() : 70}
placeholder={i18n()('zap.comment')}
disabled={query.isPending}
value={comment()}
onChange={(ev) => setComment(ev.target.value)}
/>
<button
type="submit"
class="flex w-full items-center justify-center rounded bg-primary py-4 text-primary-fg hover:bg-primary-hover"
disabled={getInvoiceMutation.isPending}
>
<span class="inline-block h-6 w-6">
<Bolt />
</span>
</button>
</form>
</div>
</Match>
</Switch>
);
};
const ZapRequestModal: Component<ZapRequestModalProps> = (props) => {
const i18n = useTranslation();
const { lud06, lud16, isZapConfigured } = useProfile(() => ({
pubkey: props.event.pubkey,
}));
const [lnurlSource, setLnurlSource] = createSignal<'lud06' | 'lud16' | undefined>();
createEffect(() => {
if (lud06() != null) setLnurlSource('lud06');
else if (lud16() != null) setLnurlSource('lud16');
});
return (
<BasicModal onClose={props.onClose}>
<div class="p-8">
<Show when={isZapConfigured()} fallback={i18n()('zap.userDidNotConfigureZap')}>
<Show when={lud06() != null && lud16() != null}>
<div class="flex justify-center gap-3 pb-2">
<button
type="button"
class="rounded border-2 border-primary p-2"
classList={{
'bg-primary': lnurlSource() === 'lud06',
'text-primary-fg': lnurlSource() === 'lud06',
'bg-bg': lnurlSource() !== 'lud06',
'text-primary': lnurlSource() !== 'lud06',
}}
onClick={() => setLnurlSource('lud06')}
>
{i18n()('zap.lud06')}
</button>
<button
type="button"
class="rounded border-2 border-primary p-2"
classList={{
'bg-primary': lnurlSource() === 'lud16',
'text-primary-fg': lnurlSource() === 'lud16',
'bg-bg': lnurlSource() !== 'lud16',
'text-primary': lnurlSource() !== 'lud16',
}}
onClick={() => setLnurlSource('lud16')}
>
{i18n()('zap.lud16')}
</button>
</div>
</Show>
<Show when={lnurlSource() === 'lud06' && lud06()} keyed>
{(value) => <ZapDialog lnurlPayUrl={lud06ToLnurlPayUrl(value)} event={props.event} />}
</Show>
<Show when={lnurlSource() === 'lud16' && lud16()} keyed>
{(value) => <ZapDialog lnurlPayUrl={lud16ToLnurlPayUrl(value)} event={props.event} />}
</Show>
</Show>
</div>
</BasicModal>
);
};
export default ZapRequestModal;

View File

@@ -1,4 +1,4 @@
import { For, Switch, Match, type Component, Show } from 'solid-js'; import { For, Switch, Match, lazy, type Component, Show } from 'solid-js';
import * as Kind from 'nostr-tools/kinds'; import * as Kind from 'nostr-tools/kinds';
import { type Event as NostrEvent } from 'nostr-tools/pure'; import { type Event as NostrEvent } from 'nostr-tools/pure';
@@ -7,9 +7,10 @@ import ColumnItem from '@/components/ColumnItem';
import Reaction from '@/components/event/Reaction'; import Reaction from '@/components/event/Reaction';
import Repost from '@/components/event/Repost'; import Repost from '@/components/event/Repost';
import TextNote from '@/components/event/TextNote'; import TextNote from '@/components/event/TextNote';
import ZapReceipt from '@/components/event/ZapReceipt';
import useConfig from '@/core/useConfig'; import useConfig from '@/core/useConfig';
const ZapReceipt = lazy(() => import('@/components/event/ZapReceipt'));
export type NotificationProps = { export type NotificationProps = {
events: NostrEvent[]; events: NostrEvent[];
}; };

View File

@@ -20,6 +20,7 @@
--color-r-sidebar: 255 228 230; --color-r-sidebar: 255 228 230;
--color-r-reaction: 251 113 133; --color-r-reaction: 251 113 133;
--color-r-repost: 74 222 128; --color-r-repost: 74 222 128;
--color-r-zap: 255 193 0;
--color-scroll-thumb: 254 205 211; --color-scroll-thumb: 254 205 211;
--color-scroll-bg: 255 255 255 / 0.7; --color-scroll-bg: 255 255 255 / 0.7;
} }
@@ -42,6 +43,7 @@
--color-r-sidebar: 155 125 110; --color-r-sidebar: 155 125 110;
--color-r-reaction: 249 110 130; --color-r-reaction: 249 110 130;
--color-r-repost: 0 190 0; --color-r-repost: 0 190 0;
--color-r-zap: 242 183 0;
--color-scroll-thumb: 49 38 38; --color-scroll-thumb: 49 38 38;
--color-scroll-bg: 245 235 222 / 0.7; --color-scroll-bg: 245 235 222 / 0.7;
} }
@@ -64,6 +66,7 @@
--color-r-sidebar: 35 27 33; --color-r-sidebar: 35 27 33;
--color-r-reaction: 251 113 133; --color-r-reaction: 251 113 133;
--color-r-repost: 139 191 67; --color-r-repost: 139 191 67;
--color-r-zap: 251 191 36;
--color-scroll-thumb: var(--color-fg-secondary); --color-scroll-thumb: var(--color-fg-secondary);
--color-scroll-bg: var(--color-bg-tertiary); --color-scroll-bg: var(--color-bg-tertiary);
} }
@@ -87,6 +90,7 @@
--color-r-sidebar: 32 39 50; --color-r-sidebar: 32 39 50;
--color-r-reaction: 249 110 130; --color-r-reaction: 249 110 130;
--color-r-repost: 0 190 0; --color-r-repost: 0 190 0;
--color-r-zap: 251 191 36;
--color-scroll-thumb: 102 102 102; --color-scroll-thumb: 102 102 102;
--color-scroll-bg: 136 136 136 / 0.1; --color-scroll-bg: 136 136 136 / 0.1;
} }
@@ -109,6 +113,7 @@
--color-r-sidebar: 0 0 0; --color-r-sidebar: 0 0 0;
--color-r-reaction: 251 113 133; --color-r-reaction: 251 113 133;
--color-r-repost: 74 222 128; --color-r-repost: 74 222 128;
--color-r-zap: 251 191 36;
--color-scroll-thumb: var(--color-fg-secondary); --color-scroll-thumb: var(--color-fg-secondary);
--color-scroll-bg: var(--color-bg-tertiary); --color-scroll-bg: var(--color-bg-tertiary);
} }

View File

@@ -123,6 +123,22 @@ export default {
reacted: ' reacted', reacted: ' reacted',
zapped: ' zapped', zapped: ' zapped',
}, },
zap: {
lud06: 'LNURL address',
lud16: 'Lightning Address',
fetchingLnUrlEndpoint: 'Fetching LNURL endpoint...',
fetchingLnUrlEndpointError: 'Failed to fetch LNURL endpoint.',
lnUrlEndpointError: 'LNURL returned an error: ',
fetchingLnUrlInvoice: 'Fetching Lightning invoice...',
fetchingLnUrlInvoiceError: 'Failed to fetch Lightning invoice.',
userDidNotConfigureZap: "You cannot Zap because the user did't configure Zap.",
lnurlServiceDoesNotAllowNostr:
"The LNURL service doesn't support Zap. This will be normal lightning payment.",
zapSplitIsNotSupported: "Zap split is not supported yet. You'll zap to the author only.",
comment: 'Comment (optional)',
sendViaWallet: 'Send via wallet',
sendViaWebLN: 'Send via extension',
},
config: { config: {
config: 'Settings', config: 'Settings',
confirmImport: 'Import? (The config will be overwritten)', confirmImport: 'Import? (The config will be overwritten)',

View File

@@ -119,6 +119,22 @@ export default {
reacted: 'がリアクション', reacted: 'がリアクション',
zapped: 'がZap', zapped: 'がZap',
}, },
zap: {
lud06: 'LNURLアドレス',
lud16: 'ライトニングアドレス',
fetchingLnUrlEndpoint: 'LNURLエンドポイントを取得中...',
fetchingLnUrlEndpointError: 'LNURLエンドポイントを取得できませんでした',
lnUrlEndpointError: 'LNURLエンドポイントがエラーを返しました: ',
fetchingLnUrlInvoice: 'ライトニングインボイスを取得中...',
fetchingLnUrlInvoiceError: 'ライトニングインボイスを取得できませんでした',
userDidNotConfigureZap: 'このユーザはZapを設定していないため、Zapできません。',
lnurlServiceDoesNotAllowNostr:
'受取人のLNURLサービスがZapをサポートしていないため、通常のライトニング送金となります。',
zapSplitIsNotSupported: 'Zap分配はまだサポートされていません。投稿者のみへの送金となります。',
comment: 'コメント (任意)',
sendViaWallet: 'ウォレットで送る',
sendViaWebLN: '拡張機能で送る',
},
config: { config: {
config: '設定', config: '設定',
confirmImport: 'インポートしますか?(現在の設定は上書きされます)', confirmImport: 'インポートしますか?(現在の設定は上書きされます)',

View File

@@ -0,0 +1,22 @@
import * as Kind from 'nostr-tools/kinds';
import { type UnsignedEvent } from 'nostr-tools/pure';
import epoch from '@/utils/epoch';
const createContacts = ({
pubkey,
updatedTags,
content,
}: {
pubkey: string;
updatedTags: string[][];
content: string;
}): UnsignedEvent => ({
kind: Kind.Contacts,
pubkey,
created_at: epoch(),
tags: updatedTags,
content,
});
export default createContacts;

View File

@@ -0,0 +1,20 @@
import * as Kind from 'nostr-tools/kinds';
import { type UnsignedEvent } from 'nostr-tools/pure';
import epoch from '@/utils/epoch';
const createDeletion = ({
pubkey,
eventId,
}: {
pubkey: string;
eventId: string;
}): UnsignedEvent => ({
kind: Kind.EventDeletion,
pubkey,
created_at: epoch(),
tags: [['e', eventId, '']],
content: '',
});
export default createDeletion;

View File

@@ -0,0 +1,31 @@
import * as Kind from 'nostr-tools/kinds';
import { type UnsignedEvent } from 'nostr-tools/pure';
import { ProfileWithOtherProperties, Profile } from '@/nostr/event/Profile';
import epoch from '@/utils/epoch';
const createProfile = ({
pubkey,
profile,
otherProperties,
}: {
pubkey: string;
profile: Profile;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
otherProperties: Record<string, any>;
}): UnsignedEvent => {
const contentObj: ProfileWithOtherProperties = {
...profile,
...otherProperties,
};
const content = JSON.stringify(contentObj);
return {
kind: Kind.Metadata,
pubkey,
created_at: epoch(),
tags: [],
content,
};
};
export default createProfile;

View File

@@ -0,0 +1,42 @@
import * as Kind from 'nostr-tools/kinds';
import { type UnsignedEvent } from 'nostr-tools/pure';
import { ReactionTypes } from '@/nostr/event/Reaction';
import epoch from '@/utils/epoch';
export type CreateReactionParams = {
pubkey: string;
eventId: string;
kind: number;
reactionTypes: ReactionTypes;
notifyPubkey: string;
};
// NIP-25
const createReaction = ({
pubkey,
eventId,
kind,
reactionTypes,
notifyPubkey,
}: CreateReactionParams): UnsignedEvent => {
const tags = [
['e', eventId, ''],
['p', notifyPubkey],
['k', kind.toString()],
];
if (reactionTypes.type === 'CustomEmoji') {
tags.push(['emoji', reactionTypes.shortcode, reactionTypes.url]);
}
return {
kind: Kind.Reaction,
pubkey,
created_at: epoch(),
tags,
content: reactionTypes.content,
};
};
export default createReaction;

View File

@@ -0,0 +1,28 @@
import * as Kind from 'nostr-tools/kinds';
import { type UnsignedEvent } from 'nostr-tools/pure';
import epoch from '@/utils/epoch';
const createRepost = ({
pubkey,
eventId,
kind,
notifyPubkey,
}: {
pubkey: string;
eventId: string;
kind: number;
notifyPubkey: string;
}): UnsignedEvent => ({
kind: kind === 1 ? Kind.Repost : 16 /* generic repost */,
pubkey,
created_at: epoch(),
tags: [
['e', eventId, ''],
['p', notifyPubkey],
['k', kind.toString()],
],
content: '',
});
export default createRepost;

View File

@@ -2,7 +2,7 @@ import assert from 'assert';
import { describe, it } from 'vitest'; import { describe, it } from 'vitest';
import { buildTags } from '@/nostr/useCommands'; import { buildTags } from '@/nostr/builder/createTextNote';
describe('buildTags', () => { describe('buildTags', () => {
it('should return a root tag if only rootEventId is given', () => { it('should return a root tag if only rootEventId is given', () => {

View File

@@ -0,0 +1,85 @@
import * as Kind from 'nostr-tools/kinds';
import { type UnsignedEvent } from 'nostr-tools/pure';
import epoch from '@/utils/epoch';
export type TagParams = {
tags?: string[][];
notifyPubkeys?: string[];
rootEventId?: string;
mentionEventIds?: string[];
replyEventId?: string;
hashtags?: string[];
urls?: string[];
contentWarning?: string;
};
export type CreateTextNoteParams = {
pubkey: string;
content: string;
} & TagParams;
export const buildTags = ({
notifyPubkeys,
rootEventId,
mentionEventIds,
replyEventId,
contentWarning,
hashtags,
urls,
tags,
}: TagParams): string[][] => {
// NIP-10
const eTags = [];
const pTags = notifyPubkeys?.map((p) => ['p', p]) ?? [];
const otherTags = [];
// the order of e tags should be [rootId, ...mentionIds, replyIds] for old clients
if (rootEventId != null) {
eTags.push(['e', rootEventId, '', 'root']);
}
// For top level replies, only the "root" marker should be used.
if (rootEventId == null && replyEventId != null) {
eTags.push(['e', replyEventId, '', 'root']);
}
if (mentionEventIds != null) {
mentionEventIds.forEach((id) => eTags.push(['e', id, '', 'mention']));
}
if (rootEventId != null && replyEventId != null && rootEventId !== replyEventId) {
eTags.push(['e', replyEventId, '', 'reply']);
}
if (hashtags != null) {
hashtags.forEach((tag) => otherTags.push(['t', tag]));
}
if (urls != null) {
urls.forEach((url) => otherTags.push(['r', url]));
}
if (contentWarning != null) {
otherTags.push(['content-warning', contentWarning]);
}
if (tags != null && tags.length > 0) {
otherTags.push(...tags);
}
return [...eTags, ...pTags, ...otherTags];
};
// NIP-01
const createTextNote = (params: CreateTextNoteParams): UnsignedEvent => {
const { pubkey, content } = params;
const tags = buildTags(params);
return {
kind: Kind.ShortTextNote,
pubkey,
created_at: epoch(),
tags,
content,
};
};
export default createTextNote;

View File

@@ -0,0 +1,45 @@
import * as Kind from 'nostr-tools/kinds';
import { type UnsignedEvent } from 'nostr-tools/pure';
import lnurlPayUrlToLud06 from '@/nostr/zap/lnurlPayUrlToLud06';
import epoch from '@/utils/epoch';
const createZapRequest = ({
pubkey,
content,
relays,
recipientPubkey,
eventId,
amountMilliSats,
lnurlPayUrl,
}: {
pubkey: string;
content: string;
relays: string[];
recipientPubkey: string;
eventId?: string;
amountMilliSats: string;
lnurlPayUrl: string;
}): UnsignedEvent => {
if (parseInt(amountMilliSats, 10) === 0) throw new Error('amount is zero');
if (relays.length === 0) throw new Error('relays is empty');
const tags: string[][] = [
['relays', ...relays],
['amount', amountMilliSats],
['lnurl', lnurlPayUrlToLud06(lnurlPayUrl)],
['p', recipientPubkey],
];
if (eventId != null) tags.push(['e', eventId]);
const event: UnsignedEvent = {
kind: Kind.ZapRequest,
pubkey,
created_at: epoch(),
tags,
content,
};
return event;
};
export default createZapRequest;

View File

@@ -3,12 +3,9 @@ import { Event as NostrEvent } from 'nostr-tools/pure';
import GenericEvent from '@/nostr/event/GenericEvent'; import GenericEvent from '@/nostr/event/GenericEvent';
import Reaction from '@/nostr/event/Reaction'; import Reaction from '@/nostr/event/Reaction';
import TextNote from '@/nostr/event/TextNote'; import TextNote from '@/nostr/event/TextNote';
import ZapReceipt from '@/nostr/event/ZapReceipt';
export const genericEvent = (event: NostrEvent): GenericEvent => new GenericEvent(event); export const genericEvent = (event: NostrEvent): GenericEvent => new GenericEvent(event);
export const textNote = (event: NostrEvent): TextNote => new TextNote(event); export const textNote = (event: NostrEvent): TextNote => new TextNote(event);
export const reaction = (event: NostrEvent): Reaction => new Reaction(event); export const reaction = (event: NostrEvent): Reaction => new Reaction(event);
export const zapReceipt = (event: NostrEvent): ZapReceipt => new ZapReceipt(event);

View File

@@ -2,10 +2,10 @@ import { type Event as NostrEvent } from 'nostr-tools/pure';
import GenericEvent from '@/nostr/event/GenericEvent'; import GenericEvent from '@/nostr/event/GenericEvent';
import ZapRequest from '@/nostr/event/ZapRequest'; import ZapRequest from '@/nostr/event/ZapRequest';
import { parseBolt11, Bolt11 } from '@/nostr/zap'; import { parseBolt11, type PaymentRequestObject } from '@/nostr/zap/bolt11';
export default class ZapReceipt extends GenericEvent { export default class ZapReceipt extends GenericEvent {
#bolt11?: Bolt11; #bolt11?: PaymentRequestObject;
#description?: ZapRequest; #description?: ZapRequest;
@@ -19,7 +19,7 @@ export default class ZapReceipt extends GenericEvent {
return this.#description; return this.#description;
} }
bolt11(): Bolt11 { bolt11(): PaymentRequestObject {
if (this.#bolt11 != null) return this.#bolt11; if (this.#bolt11 != null) return this.#bolt11;
const rawBolt11 = this.findFirstTagByName('bolt11')?.[1]; const rawBolt11 = this.findFirstTagByName('bolt11')?.[1];
@@ -28,7 +28,7 @@ export default class ZapReceipt extends GenericEvent {
return this.#bolt11; return this.#bolt11;
} }
amountSats(): number { amountSats(): number | null | undefined {
return this.bolt11().satoshis; return this.bolt11().satoshis;
} }

View File

@@ -1,75 +1,34 @@
import * as Kind from 'nostr-tools/kinds'; import {
import { verifyEvent, getEventHash, type UnsignedEvent } from 'nostr-tools/pure'; verifyEvent,
getEventHash,
type Event as NostrEvent,
type UnsignedEvent,
} from 'nostr-tools/pure';
import { ProfileWithOtherProperties, Profile } from '@/nostr/event/Profile'; import createContacts from '@/nostr/builder/createContects';
import { ReactionTypes } from '@/nostr/event/Reaction'; import createDeletion from '@/nostr/builder/createDeletion';
import createProfile from '@/nostr/builder/createProfile';
import createReaction from '@/nostr/builder/createReaction';
import createRepost from '@/nostr/builder/createRepost';
import createTextNote from '@/nostr/builder/createTextNote';
import usePool from '@/nostr/usePool'; import usePool from '@/nostr/usePool';
import epoch from '@/utils/epoch';
export type TagParams = { type WithRelayUrls<T> = T & { relayUrls: string[] };
tags?: string[][];
notifyPubkeys?: string[];
rootEventId?: string;
mentionEventIds?: string[];
replyEventId?: string;
hashtags?: string[];
urls?: string[];
contentWarning?: string;
};
export type PublishTextNoteParams = { export const signEvent = async (unsignedEvent: UnsignedEvent): Promise<NostrEvent> => {
relayUrls: string[]; const id = getEventHash(unsignedEvent);
pubkey: string; const preSignedEvent: UnsignedEvent & { id: string } = { ...unsignedEvent, id };
content: string;
} & TagParams;
export const buildTags = ({ if (window.nostr == null) {
notifyPubkeys, throw new Error('NIP-07 implementation not found');
rootEventId,
mentionEventIds,
replyEventId,
contentWarning,
hashtags,
urls,
tags,
}: TagParams): string[][] => {
// NIP-10
const eTags = [];
const pTags = notifyPubkeys?.map((p) => ['p', p]) ?? [];
const otherTags = [];
// the order of e tags should be [rootId, ...mentionIds, replyIds] for old clients
if (rootEventId != null) {
eTags.push(['e', rootEventId, '', 'root']);
} }
// For top level replies, only the "root" marker should be used. const signedEvent = await window.nostr.signEvent(preSignedEvent);
if (rootEventId == null && replyEventId != null) {
eTags.push(['e', replyEventId, '', 'root']); if (!verifyEvent({ ...signedEvent, id })) {
} throw new Error('nostr.signEvent returned invalid data');
if (mentionEventIds != null) {
mentionEventIds.forEach((id) => eTags.push(['e', id, '', 'mention']));
}
if (rootEventId != null && replyEventId != null && rootEventId !== replyEventId) {
eTags.push(['e', replyEventId, '', 'reply']);
} }
if (hashtags != null) { return signedEvent;
hashtags.forEach((tag) => otherTags.push(['t', tag]));
}
if (urls != null) {
urls.forEach((url) => otherTags.push(['r', url]));
}
if (contentWarning != null) {
otherTags.push(['content-warning', contentWarning]);
}
if (tags != null && tags.length > 0) {
otherTags.push(...tags);
}
return [...eTags, ...pTags, ...otherTags];
}; };
const useCommands = () => { const useCommands = () => {
@@ -79,17 +38,7 @@ const useCommands = () => {
relayUrls: string[], relayUrls: string[],
event: UnsignedEvent, event: UnsignedEvent,
): Promise<Promise<void>[]> => { ): Promise<Promise<void>[]> => {
const preSignedEvent: UnsignedEvent & { id?: string } = { ...event }; const signedEvent = await signEvent(event);
const id = getEventHash(preSignedEvent);
preSignedEvent.id = id;
if (window.nostr == null) {
throw new Error('NIP-07 implementation not found');
}
const signedEvent = await window.nostr.signEvent(preSignedEvent);
if (!verifyEvent({ ...signedEvent, id })) {
throw new Error('nostr.signEvent returned invalid data');
}
return relayUrls.map(async (relayUrl) => { return relayUrls.map(async (relayUrl) => {
const relay = await pool().ensureRelay(relayUrl); const relay = await pool().ensureRelay(relayUrl);
@@ -103,158 +52,21 @@ const useCommands = () => {
}); });
}; };
// NIP-01 const asPublish =
const publishTextNote = async (params: PublishTextNoteParams): Promise<Promise<void>[]> => { <P>(f: (p: P) => UnsignedEvent) =>
const { relayUrls, pubkey, content } = params; async (params: WithRelayUrls<P>) => {
const tags = buildTags(params); const unsignedEvent = f(params);
const signedEvent = await signEvent(unsignedEvent);
const preSignedEvent: UnsignedEvent = { return publishEvent(params.relayUrls, signedEvent);
kind: Kind.ShortTextNote,
pubkey,
created_at: epoch(),
tags,
content,
}; };
return publishEvent(relayUrls, preSignedEvent);
};
// NIP-25
const publishReaction = async ({
relayUrls,
pubkey,
eventId,
kind,
reactionTypes,
notifyPubkey,
}: {
relayUrls: string[];
pubkey: string;
eventId: string;
kind: number;
reactionTypes: ReactionTypes;
notifyPubkey: string;
}): Promise<Promise<void>[]> => {
const tags = [
['e', eventId, ''],
['p', notifyPubkey],
['k', kind.toString()],
];
if (reactionTypes.type === 'CustomEmoji') {
tags.push(['emoji', reactionTypes.shortcode, reactionTypes.url]);
}
const preSignedEvent: UnsignedEvent = {
kind: Kind.Reaction,
pubkey,
created_at: epoch(),
tags,
content: reactionTypes.content,
};
return publishEvent(relayUrls, preSignedEvent);
};
// NIP-18
const publishRepost = async ({
relayUrls,
pubkey,
eventId,
kind,
notifyPubkey,
}: {
relayUrls: string[];
pubkey: string;
eventId: string;
kind: number;
notifyPubkey: string;
}): Promise<Promise<void>[]> => {
const preSignedEvent: UnsignedEvent = {
kind: kind === 1 ? Kind.Repost : 16 /* generic repost */,
pubkey,
created_at: epoch(),
tags: [
['e', eventId, ''],
['p', notifyPubkey],
['k', kind.toString()],
],
content: '',
};
return publishEvent(relayUrls, preSignedEvent);
};
const updateProfile = async ({
relayUrls,
pubkey,
profile,
otherProperties,
}: {
relayUrls: string[];
pubkey: string;
profile: Profile;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
otherProperties: Record<string, any>;
}): Promise<Promise<void>[]> => {
const content: ProfileWithOtherProperties = {
...profile,
...otherProperties,
};
const preSignedEvent: UnsignedEvent = {
kind: Kind.Metadata,
pubkey,
created_at: epoch(),
tags: [],
content: JSON.stringify(content),
};
return publishEvent(relayUrls, preSignedEvent);
};
const updateContacts = async ({
relayUrls,
pubkey,
updatedTags,
content,
}: {
relayUrls: string[];
pubkey: string;
updatedTags: string[][];
content: string;
}): Promise<Promise<void>[]> => {
const preSignedEvent: UnsignedEvent = {
kind: Kind.Contacts,
pubkey,
created_at: epoch(),
tags: updatedTags,
content,
};
return publishEvent(relayUrls, preSignedEvent);
};
const deleteEvent = async ({
relayUrls,
pubkey,
eventId,
}: {
relayUrls: string[];
pubkey: string;
eventId: string;
}): Promise<Promise<void>[]> => {
const preSignedEvent: UnsignedEvent = {
kind: Kind.EventDeletion,
pubkey,
created_at: epoch(),
tags: [['e', eventId, '']],
content: '',
};
return publishEvent(relayUrls, preSignedEvent);
};
return { return {
publishTextNote, publishTextNote: asPublish(createTextNote),
publishReaction, publishReaction: asPublish(createReaction),
publishRepost, publishRepost: asPublish(createRepost),
updateProfile, updateProfile: asPublish(createProfile),
updateContacts, updateContacts: asPublish(createContacts),
deleteEvent, deleteEvent: asPublish(createDeletion),
}; };
}; };

View File

@@ -1,16 +1,28 @@
import { createMemo } from 'solid-js'; import { createMemo } from 'solid-js';
import { createQuery } from '@tanstack/solid-query'; import { createQuery, type CreateQueryResult } from '@tanstack/solid-query';
import { type Event as NostrEvent } from 'nostr-tools/pure'; import { type Event as NostrEvent } from 'nostr-tools/pure';
import isValidId from '@/nostr/event/isValidId'; import isValidId from '@/nostr/event/isValidId';
import { fetchLnurlEndpoint, verifyZapReceipt, type LnurlEndpoint } from '@/nostr/zap'; import { type LnurlError } from '@/nostr/zap/common';
import fetchLnurlEndpoint, { type LnurlEndpoint } from '@/nostr/zap/fetchLnurlEndpoint';
import verifyZapReceipt from '@/nostr/zap/verifyZapReceipt';
export type UseLnurlPayRequestMetadataProps = { export type UseLnurlEndpointProps = {
lnurlPayUrl: string; lnurlPayUrl: string;
}; };
const useLnurlEndpoint = (propsProvider: () => UseLnurlPayRequestMetadataProps | null) => { export type UseLnurlEndpoint = {
endpoint: () => LnurlEndpoint | null;
error: () => LnurlError | null;
lnurlPayUrl: () => string | undefined;
allowsNostr: () => boolean;
commentAllowed: () => number;
isZapReceiptVerified: (event: NostrEvent) => boolean;
query: CreateQueryResult<LnurlEndpoint | LnurlError | undefined>;
};
const useLnurlEndpoint = (propsProvider: () => UseLnurlEndpointProps | null): UseLnurlEndpoint => {
const props = createMemo(propsProvider); const props = createMemo(propsProvider);
const query = createQuery(() => ({ const query = createQuery(() => ({
@@ -20,6 +32,7 @@ const useLnurlEndpoint = (propsProvider: () => UseLnurlPayRequestMetadataProps |
if (params == null) return undefined; if (params == null) return undefined;
return fetchLnurlEndpoint(params.lnurlPayUrl); return fetchLnurlEndpoint(params.lnurlPayUrl);
}, },
enabled: props() != null,
staleTime: 5 * 60 * 1000, // 5 min staleTime: 5 * 60 * 1000, // 5 min
gcTime: 3 * 24 * 60 * 60 * 1000, // 3 days gcTime: 3 * 24 * 60 * 60 * 1000, // 3 days
})); }));
@@ -30,12 +43,25 @@ const useLnurlEndpoint = (propsProvider: () => UseLnurlPayRequestMetadataProps |
return data as LnurlEndpoint; return data as LnurlEndpoint;
}; };
const error = (): LnurlError | null => {
const { data } = query;
if (data == null || !('status' in data) || data.status !== 'ERROR') return null;
return data;
};
const allowsNostr = () => { const allowsNostr = () => {
const data = endpoint(); const data = endpoint();
if (data == null) return false; if (data == null) return false;
return !!data.allowsNostr && data.nostrPubkey != null && isValidId(data.nostrPubkey); return !!data.allowsNostr && data.nostrPubkey != null && isValidId(data.nostrPubkey);
}; };
const commentAllowed = (): number => {
const data = endpoint();
if (data == null) return 0;
if (data.commentAllowed == null) return 0;
return data.commentAllowed;
};
const verifyReceipt = (zapReceipt: NostrEvent) => { const verifyReceipt = (zapReceipt: NostrEvent) => {
const lnurlPayUrl = props()?.lnurlPayUrl; const lnurlPayUrl = props()?.lnurlPayUrl;
if (lnurlPayUrl == null) { if (lnurlPayUrl == null) {
@@ -66,7 +92,10 @@ const useLnurlEndpoint = (propsProvider: () => UseLnurlPayRequestMetadataProps |
return { return {
endpoint, endpoint,
error,
lnurlPayUrl: () => props()?.lnurlPayUrl,
allowsNostr, allowsNostr,
commentAllowed,
isZapReceiptVerified, isZapReceiptVerified,
query, query,
}; };

View File

@@ -14,6 +14,9 @@ export type UseProfileProps = {
export type UseProfile = { export type UseProfile = {
profile: () => ProfileWithOtherProperties | null; profile: () => ProfileWithOtherProperties | null;
event: () => NostrEvent | null | undefined; event: () => NostrEvent | null | undefined;
lud06: () => string | undefined;
lud16: () => string | undefined;
isZapConfigured: () => boolean;
invalidateProfile: () => Promise<void>; invalidateProfile: () => Promise<void>;
query: CreateQueryResult<NostrEvent | null>; query: CreateQueryResult<NostrEvent | null>;
}; };
@@ -58,10 +61,24 @@ const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile =>
return safeParseProfile(content); return safeParseProfile(content);
}); });
const lud06 = (): string | undefined => {
const p = profile();
if (p == null || p.lud06 == null || p.lud06.length === 0) return undefined;
return p.lud06;
};
const lud16 = (): string | undefined => {
const p = profile();
if (p == null || p.lud16 == null || p.lud16.length === 0) return undefined;
return p.lud16;
};
const isZapConfigured = (): boolean => lud06() != null || lud16() != null;
const invalidateProfile = (): Promise<void> => const invalidateProfile = (): Promise<void> =>
queryClient.invalidateQueries({ queryKey: genQueryKey() }); queryClient.invalidateQueries({ queryKey: genQueryKey() });
return { profile, event, invalidateProfile, query }; return { profile, lud06, lud16, event, isZapConfigured, invalidateProfile, query };
}; };
export default useProfile; export default useProfile;

View File

@@ -1,187 +0,0 @@
import { bech32 } from 'bech32';
import { type Event as NostrEvent } from 'nostr-tools/pure';
import { z } from 'zod';
import GenericEvent, { EventSchema } from '@/nostr/event/GenericEvent';
import isValidId from '@/nostr/event/isValidId';
import ensureSchema from '@/utils/ensureSchema';
export type Bolt11 = {
amount?: string;
multiplier: string;
millisatoshis: number;
satoshis: number;
};
// 1e-8 BTC = 1 satoshi.
const multiplierSatoshi = (multiplier: string): number => {
if (multiplier.length === 0) return 1e8;
if (multiplier === 'm') return 1e5;
if (multiplier === 'u') return 1e2;
if (multiplier === 'n') return 1e-1;
if (multiplier === 'p') return 1e-4;
throw new Error(`unknown multiplier: ${multiplier}`);
};
const asSatoshi = (amount: string, multiplier: string): number => {
const amountNumber = parseInt(amount, 10);
return amountNumber * multiplierSatoshi(multiplier);
};
export const parseBolt11 = (bolt11: string): Bolt11 => {
const { prefix } = bech32.decode(bolt11, 4000);
const match = prefix.match(/^ln(bc|tb|tbs|bcrt)(?<amount>\d+)(?<multiplier>[munp]?)$/);
if (match?.groups == null) throw new Error('invalid invoice format');
const { amount, multiplier } = match.groups;
if (multiplier === 'p' && amount[amount.length - 1] !== '0')
throw new Error('last decimal of amount is not zero');
const satoshis = asSatoshi(amount, multiplier);
const millisatoshis = satoshis * 1000;
return {
amount: amount.length > 0 ? amount : undefined,
multiplier,
millisatoshis,
satoshis,
};
};
const LnurlEndpointErrorSchema = z.object({
status: z.literal('ERROR'),
reason: z.string(),
});
export type LnurlError = z.infer<typeof LnurlEndpointErrorSchema>;
const LnurlEndpointSchema = z.object({
// lud06 fields
callback: z.string().nonempty(),
maxSendable: z.number().positive(),
minSendable: z.number().positive(),
metadata: z.string(),
tag: z.literal('payRequest'),
// nostr NIP-57 fields
allowsNostr: z.optional(z.boolean()),
nostrPubkey: z.optional(z.string().refine(isValidId)),
// lud12 comment
commentAllowed: z.optional(z.number()),
});
export type LnurlEndpoint = z.infer<typeof LnurlEndpointSchema>;
export const getLnurlPayUrlFromLud06 = (lud06: string): string | null => {
if (lud06.length === 0) return null;
const { prefix, words } = bech32.decode(lud06, 2000);
if (prefix.toLowerCase() !== 'lnurl') return null;
const data = bech32.fromWords(words);
return new TextDecoder('utf-8').decode(new Uint8Array(data));
};
export const getLnurlPayUrlFromLud16 = (lud16: string): string | null => {
if (lud16.length === 0) return null;
const [name, domain] = lud16.split('@');
if (domain == null) return null;
const url = new URL(`https://${domain}/`);
url.pathname = `.well-known/lnurlp/${name}`;
return url.toString();
};
export const lnurlPayUrlToLud06 = (url: string): string => {
const data = new TextEncoder().encode(url);
const words = bech32.toWords(data);
return bech32.encode('lnurl', words, 2000);
};
export const fetchLnurlEndpoint = async (lnurl: string): Promise<LnurlEndpoint | LnurlError> => {
const res = await fetch(lnurl, { mode: 'cors' });
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const body = await res.json();
if (ensureSchema(LnurlEndpointErrorSchema)(body)) return body;
if (!ensureSchema(LnurlEndpointSchema)(body)) {
throw new Error('invalid form of endpoint response');
}
return body;
};
type ZapReceiptVerificationResult = { success: true } | { success: false; reason: string };
export const verifyZapReceipt = ({
zapReceipt: rawZapReceipt,
lnurlPayUrl,
lnurlProviderPubkey,
}: {
zapReceipt: NostrEvent;
lnurlPayUrl: string;
lnurlProviderPubkey: string;
}): ZapReceiptVerificationResult => {
const zapReceipt = new GenericEvent(rawZapReceipt);
// The event's pubkey MUST be the same as the recipient's lnurl provider's pubkey.
if (zapReceipt.pubkey !== lnurlProviderPubkey) {
return { success: false, reason: 'mismatch pubkey of lnurl provider' };
}
const rawBolt11 = zapReceipt.findFirstTagByName('bolt11')?.[1];
if (rawBolt11 == null) {
return { success: false, reason: 'bolt11 tag is not found' };
}
let bolt11;
try {
bolt11 = parseBolt11(rawBolt11);
} catch (e) {
const message = e instanceof Error ? e.message : '';
return { success: false, reason: `failed to parse bolt11: ${message}` };
}
const rawZapRequest = zapReceipt.findFirstTagByName('description')?.[1];
if (rawZapRequest == null) {
return { success: false, reason: 'zap request is not found' };
}
let zapRequest;
try {
// TODO 直接EventSchema呼ぶのやめたい
zapRequest = new GenericEvent(EventSchema.parse(JSON.parse(rawZapRequest)));
} catch (e) {
const message = e instanceof Error ? e.message : '';
return { success: false, reason: `failed to parse description: ${message}` };
}
// zapRequest's amount must be equal to amount of zapReceipt's bolt11
const amount = zapRequest.findFirstTagByName('amount')?.[1];
if (amount != null && bolt11.millisatoshis.toString() !== amount) {
return {
success: false,
reason: `amount mismatch: bolt11=${bolt11.millisatoshis}, amountTag=${amount}`,
};
}
// lnurl should match
const lnurl = zapRequest.findFirstTagByName('lnurl')?.[1];
if (
lnurl != null &&
!(
lnurl.toLowerCase() === lnurlPayUrlToLud06(lnurlPayUrl).toLowerCase() ||
// for compatibility: Wallet of Satoshi
lnurl === lnurlPayUrl
)
) {
return {
success: false,
reason: `lnurl mismatch: fromProfile=${lnurlPayUrl}, request=${lnurl}`,
};
}
return { success: true };
};

6
src/nostr/zap/bolt11.ts Normal file
View File

@@ -0,0 +1,6 @@
import lightningPayReq from 'bolt11';
export { type PaymentRequestObject } from 'bolt11';
export const parseBolt11 = (bolt11: string): ReturnType<typeof lightningPayReq.decode> =>
lightningPayReq.decode(bolt11);

8
src/nostr/zap/common.ts Normal file
View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const LnurlErrorSchema = z.object({
status: z.literal('ERROR'),
reason: z.string(),
});
export type LnurlError = z.infer<typeof LnurlErrorSchema>;

View File

@@ -0,0 +1,56 @@
import { type Event as NostrEvent } from 'nostr-tools/pure';
import { z } from 'zod';
import { LnurlErrorSchema, type LnurlError } from '@/nostr/zap/common';
import lnurlPayUrlToLud06 from '@/nostr/zap/lnurlPayUrlToLud06';
import ensureSchema from '@/utils/ensureSchema';
const LnurlCallbackSchema = z.object({
pr: z.string(),
routes: z.array(z.any()).length(0),
});
export type FetchLnurlCallbackParams = {
callback: string;
lnurlPayUrl: string;
amountMilliSats: string;
comment?: string;
zapRequest?: NostrEvent;
};
export type LnurlCallback = z.infer<typeof LnurlCallbackSchema>;
const fetchLnurlCallback = async ({
callback,
lnurlPayUrl,
amountMilliSats,
comment,
zapRequest,
}: FetchLnurlCallbackParams): Promise<LnurlCallback | LnurlError> => {
const callbackUrl = new URL(callback);
callbackUrl.searchParams.set('amount', amountMilliSats.toString());
callbackUrl.searchParams.set('lnurl', lnurlPayUrlToLud06(lnurlPayUrl));
if (comment != null && comment.length > 0) {
callbackUrl.searchParams.set('comment', comment);
}
if (zapRequest != null) {
callbackUrl.searchParams.set('nostr', JSON.stringify(zapRequest));
}
const res = await fetch(callbackUrl, { mode: 'cors' });
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const body = await res.json();
if (ensureSchema(LnurlErrorSchema)(body)) return body;
if (!ensureSchema(LnurlCallbackSchema)(body)) {
throw new Error('invalid form of callback response');
}
return body;
};
export default fetchLnurlCallback;

View File

@@ -0,0 +1,22 @@
import assert from 'assert';
import { describe, it } from 'vitest';
import { parseLnurlEndpointMetadata } from '@/nostr/zap/fetchLnurlEndpoint';
describe('parseLnurlEndpointMetadata', () => {
it('should parse endpoint metadata correctly', () => {
const metadata =
'[["text/plain","Pay to Wallet of Satoshi user: vitalalloy83"],["text/identifier","vitalalloy83@walletofsatoshi.com"]]';
const actual = parseLnurlEndpointMetadata(metadata);
const expected = {
textPlain: 'Pay to Wallet of Satoshi user: vitalalloy83',
identifier: 'vitalalloy83@walletofsatoshi.com',
textLongDesc: undefined,
imagePNG: undefined,
imageJPEG: undefined,
rest: [],
};
assert.deepStrictEqual(actual, expected);
});
});

View File

@@ -0,0 +1,105 @@
import { z } from 'zod';
import isValidId from '@/nostr/event/isValidId';
import { LnurlErrorSchema, type LnurlError } from '@/nostr/zap/common';
import ensureSchema from '@/utils/ensureSchema';
const RawLnurlEndpointMetadataKnownFieldsSchema = z.union([
z.tuple([z.literal('text/plain'), z.string()]),
z.tuple([z.literal('text/long-desc'), z.string()]),
z.tuple([z.literal('image/png;base64'), z.string()]),
z.tuple([z.literal('image/jpeg;base64'), z.string()]),
// lud16 identifier
z.tuple([z.literal('text/identifier'), z.string()]),
]);
type RawLnurlEndpointMetadataKnownFields = z.infer<
typeof RawLnurlEndpointMetadataKnownFieldsSchema
>;
const RawLnurlEndpointMetadataSchema = z
.array(z.union([RawLnurlEndpointMetadataKnownFieldsSchema, z.tuple([z.string(), z.any()])]))
.refine((metadata) => metadata.findIndex(([key]) => key === 'text/plain') >= 0);
export type RawLnurlEndpointMetadata = z.infer<typeof RawLnurlEndpointMetadataSchema>;
type LnurlEndpointMetadata = {
textPlain: string;
textLongDesc?: string;
imagePNG?: string;
imageJPEG?: string;
identifier?: string;
rest: [string, string][];
};
const LnurlEndpointSchema = z.object({
// lud06 fields
callback: z.string().nonempty(),
maxSendable: z.number().positive(),
minSendable: z.number().positive(),
metadata: z.string(),
tag: z.literal('payRequest'),
// nostr NIP-57 fields
allowsNostr: z.optional(z.boolean()),
nostrPubkey: z.optional(z.string().refine(isValidId)),
// lud12 comment
commentAllowed: z.optional(z.number()),
});
export type LnurlEndpoint = z.infer<typeof LnurlEndpointSchema> & {
decodedMetadata: LnurlEndpointMetadata;
};
export const parseLnurlEndpointMetadata = (
metadataString: string,
): LnurlEndpointMetadata | null => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const metadata = JSON.parse(metadataString);
if (!ensureSchema(RawLnurlEndpointMetadataSchema)(metadata)) return null;
const rest = [...metadata];
const findAndRemove = <T extends RawLnurlEndpointMetadataKnownFields>(
key: T[0],
): T[1] | undefined => {
const index = rest.findIndex(([k]) => k === key);
if (index < 0) return undefined;
const tuple = rest[index] as T;
rest.splice(index, 1);
return tuple[1];
};
const textPlain = findAndRemove<['text/plain', string]>('text/plain');
if (textPlain == null) return null;
const textLongDesc = findAndRemove('text/long-desc');
const imagePNG = findAndRemove('image/png;base64');
const imageJPEG = findAndRemove('image/jpeg;base64');
const identifier = findAndRemove('text/identifier');
return { textPlain, textLongDesc, imagePNG, imageJPEG, identifier, rest };
} catch {
return null;
}
};
const fetchLnurlEndpoint = async (lnurl: string): Promise<LnurlEndpoint | LnurlError> => {
const res = await fetch(lnurl, { mode: 'cors' });
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const body = await res.json();
if (ensureSchema(LnurlErrorSchema)(body)) return body;
if (!ensureSchema(LnurlEndpointSchema)(body)) {
throw new Error('invalid form of endpoint response');
}
const decodedMetadata = parseLnurlEndpointMetadata(body.metadata);
if (decodedMetadata == null) {
throw new Error('invalid form of metadata');
}
return { ...body, decodedMetadata };
};
export default fetchLnurlEndpoint;

View File

@@ -0,0 +1,9 @@
import { bech32 } from 'bech32';
const lnurlPayUrlToLud06 = (url: string): string => {
const data = new TextEncoder().encode(url);
const words = bech32.toWords(data);
return bech32.encode('lnurl', words, 2000);
};
export default lnurlPayUrlToLud06;

View File

@@ -0,0 +1,11 @@
import { bech32 } from 'bech32';
const lud06ToLnurlPayUrl = (lud06: string): string | null => {
if (lud06.length === 0) return null;
const { prefix, words } = bech32.decode(lud06, 2000);
if (prefix.toLowerCase() !== 'lnurl') return null;
const data = bech32.fromWords(words);
return new TextDecoder('utf-8').decode(new Uint8Array(data));
};
export default lud06ToLnurlPayUrl;

View File

@@ -0,0 +1,12 @@
const lud16ToLnurlPayUrl = (lud16: string): string | null => {
if (lud16.length === 0) return null;
const [name, domain] = lud16.split('@');
if (domain == null) return null;
const url = new URL(`https://${domain}/`);
url.pathname = `.well-known/lnurlp/${name}`;
return url.toString();
};
export default lud16ToLnurlPayUrl;

View File

@@ -0,0 +1,22 @@
import assert from 'assert';
import { describe, it } from 'vitest';
import { parseLnurlEndpointMetadata } from '@/nostr/zap/fetchLnurlEndpoint';
describe('parseLnurlEndpointMetadata', () => {
it('should fail if the given event is not text note', () => {
const metadata =
'[["text/plain","Pay to Wallet of Satoshi user: vitalalloy83"],["text/identifier","vitalalloy83@walletofsatoshi.com"]]';
const actual = parseLnurlEndpointMetadata(metadata);
const expected = {
textPlain: 'Pay to Wallet of Satoshi user: vitalalloy83',
identifier: 'vitalalloy83@walletofsatoshi.com',
textLongDesc: undefined,
imagePNG: undefined,
imageJPEG: undefined,
rest: [],
};
assert.deepStrictEqual(actual, expected);
});
});

View File

@@ -0,0 +1,22 @@
import { type Event as NostrEvent } from 'nostr-tools/pure';
import { parseBolt11 } from '@/nostr/zap/bolt11';
import sha256Hex from '@/utils/sha256Hex';
const verifyInvoice = async (
bolt11: string,
requirements: { amountMilliSats: string; metadata: string; zapRequest?: NostrEvent },
): Promise<boolean> => {
const payReq = parseBolt11(bolt11);
return (
(requirements.zapRequest != null
? payReq.tagsObject.purpose_commit_hash ===
(await sha256Hex(JSON.stringify(requirements.zapRequest)))
: payReq.tagsObject.purpose_commit_hash === (await sha256Hex(requirements.metadata))) &&
payReq.millisatoshis != null &&
payReq.millisatoshis === requirements.amountMilliSats
);
};
export default verifyInvoice;

View File

@@ -0,0 +1,80 @@
import { type Event as NostrEvent } from 'nostr-tools/pure';
import GenericEvent, { EventSchema } from '@/nostr/event/GenericEvent';
import { parseBolt11 } from '@/nostr/zap/bolt11';
import lnurlPayUrlToLud06 from '@/nostr/zap/lnurlPayUrlToLud06';
export type ZapReceiptVerificationResult = { success: true } | { success: false; reason: string };
const verifyZapReceipt = ({
zapReceipt: rawZapReceipt,
lnurlPayUrl,
lnurlProviderPubkey,
}: {
zapReceipt: NostrEvent;
lnurlPayUrl: string;
lnurlProviderPubkey: string;
}): ZapReceiptVerificationResult => {
const zapReceipt = new GenericEvent(rawZapReceipt);
// The event's pubkey MUST be the same as the recipient's lnurl provider's pubkey.
if (zapReceipt.pubkey !== lnurlProviderPubkey) {
return { success: false, reason: 'mismatch pubkey of lnurl provider' };
}
const rawBolt11 = zapReceipt.findFirstTagByName('bolt11')?.[1];
if (rawBolt11 == null) {
return { success: false, reason: 'bolt11 tag is not found' };
}
let bolt11;
try {
bolt11 = parseBolt11(rawBolt11);
} catch (e) {
const message = e instanceof Error ? e.message : '';
return { success: false, reason: `failed to parse bolt11: ${message}` };
}
const rawZapRequest = zapReceipt.findFirstTagByName('description')?.[1];
if (rawZapRequest == null) {
return { success: false, reason: 'zap request is not found' };
}
let zapRequest;
try {
// TODO 直接EventSchema呼ぶのやめたい
zapRequest = new GenericEvent(EventSchema.parse(JSON.parse(rawZapRequest)));
} catch (e) {
const message = e instanceof Error ? e.message : '';
return { success: false, reason: `failed to parse description: ${message}` };
}
// zapRequest's amount must be equal to amount of zapReceipt's bolt11
const amount = zapRequest.findFirstTagByName('amount')?.[1];
if (amount != null && bolt11.millisatoshis !== amount) {
return {
success: false,
reason: `amount mismatch: bolt11=${bolt11.millisatoshis}, amountTag=${amount}`,
};
}
// lnurl should match
const lnurl = zapRequest.findFirstTagByName('lnurl')?.[1];
if (
lnurl != null &&
!(
lnurl.toLowerCase() === lnurlPayUrlToLud06(lnurlPayUrl).toLowerCase() ||
// for compatibility: Wallet of Satoshi
lnurl === lnurlPayUrl
)
) {
return {
success: false,
reason: `lnurl mismatch: fromProfile=${lnurlPayUrl}, request=${lnurl}`,
};
}
return { success: true };
};
export default verifyZapReceipt;

9
src/utils/sha256Hex.ts Normal file
View File

@@ -0,0 +1,9 @@
const sha256Hex = async (text: string): Promise<string> => {
const encoder = new TextEncoder();
const utf8Array = encoder.encode(text);
const sha256Buffer = await crypto.subtle.digest('SHA-256', utf8Array);
const sha256 = new Uint8Array(sha256Buffer);
return [...sha256].map((b) => b.toString(16).padStart(2, '0')).join('');
};
export default sha256Hex;

View File

@@ -35,6 +35,7 @@ export default {
sidebar: 'rgb(var(--color-r-sidebar))', sidebar: 'rgb(var(--color-r-sidebar))',
reaction: 'rgb(var(--color-r-reaction))', reaction: 'rgb(var(--color-r-reaction))',
repost: 'rgb(var(--color-r-repost))', repost: 'rgb(var(--color-r-repost))',
zap: 'rgb(var(--color-r-zap))',
}, },
}, },
}, },

View File

@@ -1,10 +1,17 @@
/* eslint import/no-extraneous-dependencies: 0 */ /* eslint import/no-extraneous-dependencies: 0 */
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import solidPlugin from 'vite-plugin-solid'; import solidPlugin from 'vite-plugin-solid';
import solidSvg from 'vite-plugin-solid-svg'; import solidSvg from 'vite-plugin-solid-svg';
export default defineConfig({ export default defineConfig({
plugins: [solidPlugin(), solidSvg()], plugins: [
solidPlugin(),
solidSvg(),
nodePolyfills({
include: ['buffer', 'stream'],
}),
],
server: { server: {
port: 3000, port: 3000,
}, },