create-fedi-app 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -0
- package/dist/index.js +58 -9
- package/dist/templates/base/.env.example +13 -0
- package/dist/templates/base/app/page.tsx +97 -6
- package/dist/templates/base/components/InvoiceQr.tsx +31 -0
- package/dist/templates/base/components/PaymentCallout.tsx +55 -0
- package/dist/templates/base/gitignore +46 -0
- package/dist/templates/base/hooks/useIsMounted.ts +14 -0
- package/dist/templates/base/lib/__tests__/fedi.test.ts +23 -0
- package/dist/templates/base/lib/demo-routes.ts +1 -1
- package/dist/templates/base/lib/fedi-types.ts +10 -0
- package/dist/templates/base/lib/fedi.ts +6 -0
- package/dist/templates/base/lib/lightning/bolt11.ts +15 -0
- package/dist/templates/base/lib/lightning/lnurl-pay.ts +97 -0
- package/dist/templates/base/lib/lightning/preimage-verify.ts +31 -0
- package/dist/templates/base/lib/nostr/hooks.ts +12 -3
- package/dist/templates/base/lib/nostr/provider.tsx +44 -25
- package/dist/templates/base/lib/payment-config.ts +54 -0
- package/dist/templates/base/lib/webln/hooks.ts +15 -5
- package/dist/templates/base/lib/webln/provider.tsx +41 -17
- package/dist/templates/base/package.json +1 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/PaymentGate.tsx +1 -28
- package/dist/templates/modules/ai-rules/rules/OVERVIEW.md +2 -1
- package/dist/templates/modules/ai-rules/rules/fedi-api.md +20 -0
- package/dist/templates/modules/ecash-balance/app/demo/ecash/page.tsx +23 -6
- package/dist/templates/modules/ecash-balance/components/fedi/BalanceDisplay.tsx +33 -8
- package/dist/templates/modules/ecash-balance/components/fedi/InstallMiniAppButton.tsx +28 -19
- package/dist/templates/modules/ecash-balance/components/fedi/ManageMiniAppsPermissionHint.tsx +48 -0
- package/dist/templates/modules/ecash-balance/hooks/useFediBalance.ts +56 -29
- package/dist/templates/modules/ecash-balance/module.json +1 -0
- package/dist/templates/modules/lnurl/app/demo/lnurl/page.tsx +17 -9
- package/dist/templates/modules/lnurl/components/lnurl/LnurlErrorBoundary.tsx +49 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlPay.tsx +64 -13
- package/dist/templates/modules/lnurl/components/lnurl/LnurlQR.tsx +36 -2
- package/dist/templates/modules/lnurl/components/lnurl/LnurlWithdraw.tsx +2 -1
- package/dist/templates/modules/lnurl/module.json +1 -0
- package/dist/templates/modules/payment-gated-content/app/api/payment-gate/invoice/route.ts +7 -2
- package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/article/page.tsx +66 -16
- package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/page.tsx +13 -14
- package/dist/templates/modules/payment-gated-content/components/payment-gated/PayGate.tsx +45 -70
- package/dist/templates/modules/payment-gated-content/lib/payment-gate.ts +20 -15
- package/dist/templates/modules/payment-gated-content/lib/payment-store.ts +5 -22
- package/dist/templates/modules/webln-payments/app/demo/webln/page.tsx +13 -7
- package/dist/templates/modules/webln-payments/components/webln/InvoiceCard.tsx +29 -82
- package/dist/templates/modules/webln-payments/tests/e2e/webln-payment.spec.ts +2 -0
- package/package.json +10 -1
|
@@ -7,6 +7,12 @@ export function getFediInternalVersion(): 0 | 1 | 2 | null {
|
|
|
7
7
|
return window.fediInternal.version as 0 | 1 | 2;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/** True when Fedi rejected a call due to a missing or denied mini-app permission. */
|
|
11
|
+
export function isFediPermissionError(err: unknown): boolean {
|
|
12
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
13
|
+
return /permission denied|missing the following permissions/i.test(message);
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
export function formatSats(sats: number): string {
|
|
11
17
|
if (sats >= 100_000) return `${(sats / 100_000_000).toFixed(6)} BTC`;
|
|
12
18
|
return `${sats.toLocaleString()} sats`;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { decode } from 'light-bolt11-decoder';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extracts the payment hash (hex) from a BOLT11 invoice string.
|
|
5
|
+
*/
|
|
6
|
+
export function getPaymentHashFromInvoice(invoice: string): string {
|
|
7
|
+
const decoded = decode(invoice);
|
|
8
|
+
const section = decoded.sections.find((s) => s.name === 'payment_hash');
|
|
9
|
+
|
|
10
|
+
if (!section || typeof section.value !== 'string') {
|
|
11
|
+
throw new Error('Invoice missing payment_hash');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return section.value;
|
|
15
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { bech32 } from '@scure/base';
|
|
2
|
+
|
|
3
|
+
export interface ILnurlPayMetadata {
|
|
4
|
+
callback: string;
|
|
5
|
+
minSendable: number;
|
|
6
|
+
maxSendable: number;
|
|
7
|
+
metadata?: string;
|
|
8
|
+
tag?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ILnurlPayInvoiceResponse {
|
|
12
|
+
pr: string;
|
|
13
|
+
routes?: unknown[];
|
|
14
|
+
successAction?: { tag: string; message?: string };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Decodes a bech32 LNURL string to the underlying HTTPS endpoint. */
|
|
18
|
+
export function decodeLnurlPayAddress(lnurl: string): string {
|
|
19
|
+
const normalized = lnurl.trim().toLowerCase();
|
|
20
|
+
if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
|
|
21
|
+
return lnurl.trim();
|
|
22
|
+
}
|
|
23
|
+
const { words } = bech32.decode(normalized, 2000);
|
|
24
|
+
const bytes = bech32.fromWords(words);
|
|
25
|
+
return new TextDecoder().decode(bytes);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Resolves an LNURL-pay HTTPS endpoint from a bech32 or raw URL string. */
|
|
29
|
+
export function resolveLnurlPayEndpoint(address: string): string {
|
|
30
|
+
const trimmed = address.trim();
|
|
31
|
+
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
|
32
|
+
return trimmed;
|
|
33
|
+
}
|
|
34
|
+
return decodeLnurlPayAddress(trimmed);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fetches LNURL-pay metadata (LUD-06). */
|
|
38
|
+
export async function fetchLnurlPayMetadata(endpoint: string): Promise<ILnurlPayMetadata> {
|
|
39
|
+
const res = await fetch(endpoint);
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
throw new Error(`LNURL metadata lookup failed (${res.status})`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const data = (await res.json()) as ILnurlPayMetadata;
|
|
45
|
+
if (!data.callback || !data.minSendable || !data.maxSendable) {
|
|
46
|
+
throw new Error('Invalid LNURL-pay metadata response');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Requests a BOLT11 invoice from an LNURL-pay callback. */
|
|
53
|
+
export async function requestLnurlPayInvoice(
|
|
54
|
+
callback: string,
|
|
55
|
+
amountMsats: number,
|
|
56
|
+
comment?: string,
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
const url = new URL(callback);
|
|
59
|
+
url.searchParams.set('amount', String(amountMsats));
|
|
60
|
+
if (comment) {
|
|
61
|
+
url.searchParams.set('comment', comment);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const res = await fetch(url.toString());
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
throw new Error(`LNURL invoice request failed (${res.status})`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const data = (await res.json()) as ILnurlPayInvoiceResponse;
|
|
70
|
+
if (!data.pr) {
|
|
71
|
+
throw new Error('LNURL response did not include an invoice');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return data.pr;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Creates a real BOLT11 invoice via an LNURL-pay address.
|
|
79
|
+
*/
|
|
80
|
+
export async function createLnurlPayInvoice(
|
|
81
|
+
lnurlAddress: string,
|
|
82
|
+
amountSats: number,
|
|
83
|
+
comment?: string,
|
|
84
|
+
): Promise<{ invoice: string; amountMsats: number }> {
|
|
85
|
+
const endpoint = resolveLnurlPayEndpoint(lnurlAddress);
|
|
86
|
+
const metadata = await fetchLnurlPayMetadata(endpoint);
|
|
87
|
+
const amountMsats = amountSats * 1000;
|
|
88
|
+
|
|
89
|
+
if (amountMsats < metadata.minSendable || amountMsats > metadata.maxSendable) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Amount ${amountSats} sats is outside LNURL limits (${Math.floor(metadata.minSendable / 1000)}–${Math.floor(metadata.maxSendable / 1000)} sats)`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const invoice = await requestLnurlPayInvoice(metadata.callback, amountMsats, comment);
|
|
96
|
+
return { invoice, amountMsats };
|
|
97
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
2
|
+
|
|
3
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
4
|
+
const normalized = hex.replace(/^0x/i, '');
|
|
5
|
+
const bytes = new Uint8Array(normalized.length / 2);
|
|
6
|
+
for (let i = 0; i < normalized.length; i += 2) {
|
|
7
|
+
bytes[i / 2] = Number.parseInt(normalized.slice(i, i + 2), 16);
|
|
8
|
+
}
|
|
9
|
+
return bytes;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
13
|
+
return Array.from(bytes)
|
|
14
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
15
|
+
.join('');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Verifies that a Lightning payment preimage matches the expected payment hash.
|
|
20
|
+
*/
|
|
21
|
+
export function verifyPreimage(preimage: string, paymentHashHex: string): boolean {
|
|
22
|
+
try {
|
|
23
|
+
const preimageBytes = hexToBytes(preimage);
|
|
24
|
+
const hash = sha256(preimageBytes);
|
|
25
|
+
const expected = hexToBytes(paymentHashHex);
|
|
26
|
+
if (hash.length !== expected.length) return false;
|
|
27
|
+
return bytesToHex(hash) === bytesToHex(expected);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -21,27 +21,36 @@ export function useNostr() {
|
|
|
21
21
|
provider: ctx.provider,
|
|
22
22
|
pubkey: ctx.pubkey,
|
|
23
23
|
npub,
|
|
24
|
+
isAvailable: ctx.isAvailable,
|
|
24
25
|
isLoading: ctx.isLoading,
|
|
25
26
|
error: ctx.error,
|
|
26
|
-
isConnected: ctx.
|
|
27
|
+
isConnected: ctx.pubkey !== null,
|
|
28
|
+
connect: ctx.connect,
|
|
27
29
|
};
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export function useIdentity() {
|
|
31
|
-
const { provider, pubkey, npub } = useNostr();
|
|
33
|
+
const { provider, pubkey, npub, connect } = useNostr();
|
|
32
34
|
const [isConnecting, setIsConnecting] = useState(false);
|
|
33
35
|
|
|
34
36
|
const displayNpub = npub ? npub.slice(0, 8) + '...' + npub.slice(-4) : null;
|
|
35
37
|
|
|
36
38
|
async function getPublicKey(): Promise<string | null> {
|
|
39
|
+
if (pubkey) return pubkey;
|
|
37
40
|
if (!provider) return null;
|
|
38
|
-
|
|
41
|
+
setIsConnecting(true);
|
|
42
|
+
try {
|
|
43
|
+
return await connect();
|
|
44
|
+
} finally {
|
|
45
|
+
setIsConnecting(false);
|
|
46
|
+
}
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
async function signEvent(event: UnsignedNostrEvent): Promise<NostrEvent | null> {
|
|
42
50
|
if (!provider) return null;
|
|
43
51
|
setIsConnecting(true);
|
|
44
52
|
try {
|
|
53
|
+
await getPublicKey();
|
|
45
54
|
return await provider.signEvent(event);
|
|
46
55
|
} finally {
|
|
47
56
|
setIsConnecting(false);
|
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from 'react';
|
|
2
9
|
import type { NostrProvider as NostrProviderInterface } from '../fedi-types';
|
|
3
10
|
|
|
4
11
|
interface NostrContextValue {
|
|
5
12
|
provider: NostrProviderInterface | null;
|
|
6
13
|
pubkey: string | null;
|
|
14
|
+
isAvailable: boolean;
|
|
7
15
|
isLoading: boolean;
|
|
8
16
|
error: Error | null;
|
|
17
|
+
connect: () => Promise<string | null>;
|
|
9
18
|
}
|
|
10
19
|
|
|
11
20
|
export const NostrContext = createContext<NostrContextValue | null>(null);
|
|
@@ -18,46 +27,56 @@ interface NostrProviderProps {
|
|
|
18
27
|
export function NostrProvider({ children, mockProvider }: NostrProviderProps) {
|
|
19
28
|
const [provider, setProvider] = useState<NostrProviderInterface | null>(null);
|
|
20
29
|
const [pubkey, setPubkey] = useState<string | null>(null);
|
|
30
|
+
const [isAvailable, setIsAvailable] = useState(false);
|
|
21
31
|
const [isLoading, setIsLoading] = useState(true);
|
|
22
32
|
const [error, setError] = useState<Error | null>(null);
|
|
33
|
+
const availableRef = useRef<NostrProviderInterface | null>(null);
|
|
23
34
|
|
|
24
35
|
useEffect(() => {
|
|
25
36
|
let cancelled = false;
|
|
26
37
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
} else if (mockProvider !== undefined && process.env.NODE_ENV === 'development') {
|
|
33
|
-
activeProvider = mockProvider;
|
|
38
|
+
if (typeof window !== 'undefined' && window.nostr) {
|
|
39
|
+
availableRef.current = window.nostr;
|
|
40
|
+
if (!cancelled) {
|
|
41
|
+
setProvider(window.nostr);
|
|
42
|
+
setIsAvailable(true);
|
|
34
43
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
setProvider(activeProvider);
|
|
41
|
-
setPubkey(pk);
|
|
42
|
-
}
|
|
43
|
-
} catch (err) {
|
|
44
|
-
if (!cancelled) {
|
|
45
|
-
setError(err instanceof Error ? err : new Error('Failed to connect Nostr'));
|
|
46
|
-
}
|
|
47
|
-
}
|
|
44
|
+
} else if (mockProvider !== undefined && process.env.NODE_ENV === 'development') {
|
|
45
|
+
availableRef.current = mockProvider;
|
|
46
|
+
if (!cancelled) {
|
|
47
|
+
setProvider(mockProvider);
|
|
48
|
+
setIsAvailable(true);
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
-
if (!cancelled) setIsLoading(false);
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
|
|
52
|
+
if (!cancelled) setIsLoading(false);
|
|
53
|
+
|
|
54
54
|
return () => {
|
|
55
55
|
cancelled = true;
|
|
56
56
|
};
|
|
57
57
|
}, [mockProvider]);
|
|
58
58
|
|
|
59
|
+
const connect = useCallback(async (): Promise<string | null> => {
|
|
60
|
+
const active = availableRef.current;
|
|
61
|
+
if (!active) return null;
|
|
62
|
+
|
|
63
|
+
setError(null);
|
|
64
|
+
try {
|
|
65
|
+
const pk = await active.getPublicKey();
|
|
66
|
+
setPubkey(pk);
|
|
67
|
+
return pk;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
const connectError =
|
|
70
|
+
err instanceof Error ? err : new Error('Failed to connect Nostr');
|
|
71
|
+
setError(connectError);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
59
76
|
return (
|
|
60
|
-
<NostrContext.Provider
|
|
77
|
+
<NostrContext.Provider
|
|
78
|
+
value={{ provider, pubkey, isAvailable, isLoading, error, connect }}
|
|
79
|
+
>
|
|
61
80
|
{children}
|
|
62
81
|
</NostrContext.Provider>
|
|
63
82
|
);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Default LNURL-pay address for create-fedi-app demo payments (maintainer wallet). */
|
|
2
|
+
export const DEFAULT_LNURL_PAY_ADDRESS =
|
|
3
|
+
'lnurl1dp68gurn8ghj7un9vd6hyunfdenkgtnrw3exytnfduhkcmnkxyhhqctevdhkgetn9uenzvmxv33kyefj8yerqefk8p3rvd34x93nvdr9vvukgcfexcergv3jxc6kgcm9x4jxydenxvengwf48q6rxwpsxf3ryctpvvenwefswhw4au';
|
|
4
|
+
|
|
5
|
+
export const PUBLIC_FEDI_APP_REPO = 'https://github.com/keegan-lee/public-fedi-app';
|
|
6
|
+
|
|
7
|
+
function parsePositiveInt(value: string | undefined, fallback: number): number {
|
|
8
|
+
const parsed = Number.parseInt(value ?? '', 10);
|
|
9
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Server-side LNURL-pay address for invoice generation. */
|
|
13
|
+
export function getLnurlPayAddress(): string {
|
|
14
|
+
return (
|
|
15
|
+
process.env.LNURL_PAY_ADDRESS?.trim() ||
|
|
16
|
+
process.env.NEXT_PUBLIC_LNURL_PAY_ADDRESS?.trim() ||
|
|
17
|
+
DEFAULT_LNURL_PAY_ADDRESS
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Client-safe LNURL-pay address for display and QR codes. */
|
|
22
|
+
export function getPublicLnurlPayAddress(): string {
|
|
23
|
+
return (
|
|
24
|
+
process.env.NEXT_PUBLIC_LNURL_PAY_ADDRESS?.trim() ||
|
|
25
|
+
DEFAULT_LNURL_PAY_ADDRESS
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getDemoWeblnSats(): number {
|
|
30
|
+
return parsePositiveInt(
|
|
31
|
+
process.env.NEXT_PUBLIC_DEMO_WEBLN_SATS ?? process.env.DEMO_WEBLN_SATS,
|
|
32
|
+
21,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getDemoGateSats(): number {
|
|
37
|
+
return parsePositiveInt(
|
|
38
|
+
process.env.NEXT_PUBLIC_DEMO_GATE_SATS ?? process.env.DEMO_GATE_SATS,
|
|
39
|
+
7,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getDemoLnurlSats(): number {
|
|
44
|
+
return parsePositiveInt(
|
|
45
|
+
process.env.NEXT_PUBLIC_DEMO_LNURL_SATS ?? process.env.DEMO_LNURL_SATS,
|
|
46
|
+
1,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Truncates an LNURL string for compact UI display. */
|
|
51
|
+
export function truncateLnurl(lnurl: string): string {
|
|
52
|
+
if (lnurl.length <= 36) return lnurl;
|
|
53
|
+
return `${lnurl.slice(0, 24)}…${lnurl.slice(-12)}`;
|
|
54
|
+
}
|
|
@@ -9,26 +9,35 @@ export function useWebLN() {
|
|
|
9
9
|
}
|
|
10
10
|
return {
|
|
11
11
|
provider: ctx.provider,
|
|
12
|
+
isAvailable: ctx.isAvailable,
|
|
12
13
|
isLoading: ctx.isLoading,
|
|
13
14
|
error: ctx.error,
|
|
14
15
|
isConnected: ctx.provider !== null,
|
|
16
|
+
connect: ctx.connect,
|
|
15
17
|
};
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export function usePayment() {
|
|
19
|
-
const { provider } = useWebLN();
|
|
21
|
+
const { provider, connect, isAvailable } = useWebLN();
|
|
20
22
|
const [isPaying, setIsPaying] = useState(false);
|
|
21
23
|
const [isCreatingInvoice, setIsCreatingInvoice] = useState(false);
|
|
22
24
|
const [paymentError, setPaymentError] = useState<Error | null>(null);
|
|
23
25
|
const [lastPreimage, setLastPreimage] = useState<string | null>(null);
|
|
24
26
|
const [lastInvoice, setLastInvoice] = useState<string | null>(null);
|
|
25
27
|
|
|
28
|
+
async function ensureEnabled() {
|
|
29
|
+
if (provider) return provider;
|
|
30
|
+
if (!isAvailable) return null;
|
|
31
|
+
return connect();
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
async function sendPayment(paymentRequest: string) {
|
|
27
|
-
|
|
35
|
+
const active = await ensureEnabled();
|
|
36
|
+
if (!active) return null;
|
|
28
37
|
setIsPaying(true);
|
|
29
38
|
setPaymentError(null);
|
|
30
39
|
try {
|
|
31
|
-
const result = await
|
|
40
|
+
const result = await active.sendPayment(paymentRequest);
|
|
32
41
|
setLastPreimage(result.preimage);
|
|
33
42
|
return result;
|
|
34
43
|
} catch (err) {
|
|
@@ -40,11 +49,12 @@ export function usePayment() {
|
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
async function makeInvoice(args: RequestInvoiceArgs | string | number) {
|
|
43
|
-
|
|
52
|
+
const active = await ensureEnabled();
|
|
53
|
+
if (!active) return null;
|
|
44
54
|
setIsCreatingInvoice(true);
|
|
45
55
|
setPaymentError(null);
|
|
46
56
|
try {
|
|
47
|
-
const result = await
|
|
57
|
+
const result = await active.makeInvoice(args);
|
|
48
58
|
setLastInvoice(result.paymentRequest);
|
|
49
59
|
return result;
|
|
50
60
|
} catch (err) {
|
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from 'react';
|
|
2
9
|
import type { WebLNProvider as WebLNProviderInterface } from '../fedi-types';
|
|
3
10
|
|
|
4
11
|
interface WebLNContextValue {
|
|
12
|
+
/** Enabled WebLN provider; null until `connect()` succeeds. */
|
|
5
13
|
provider: WebLNProviderInterface | null;
|
|
14
|
+
isAvailable: boolean;
|
|
6
15
|
isLoading: boolean;
|
|
7
16
|
error: Error | null;
|
|
17
|
+
connect: () => Promise<WebLNProviderInterface | null>;
|
|
8
18
|
}
|
|
9
19
|
|
|
10
20
|
export const WebLNContext = createContext<WebLNContextValue | null>(null);
|
|
@@ -16,36 +26,50 @@ interface WebLNProviderProps {
|
|
|
16
26
|
|
|
17
27
|
export function WebLNProvider({ children, mockProvider }: WebLNProviderProps) {
|
|
18
28
|
const [provider, setProvider] = useState<WebLNProviderInterface | null>(null);
|
|
29
|
+
const [isAvailable, setIsAvailable] = useState(false);
|
|
19
30
|
const [isLoading, setIsLoading] = useState(true);
|
|
20
31
|
const [error, setError] = useState<Error | null>(null);
|
|
32
|
+
const availableRef = useRef<WebLNProviderInterface | null>(null);
|
|
21
33
|
|
|
22
34
|
useEffect(() => {
|
|
23
35
|
let cancelled = false;
|
|
24
36
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (!cancelled) {
|
|
32
|
-
setError(err instanceof Error ? err : new Error('Failed to enable WebLN'));
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
} else if (mockProvider !== undefined && process.env.NODE_ENV !== 'production') {
|
|
36
|
-
if (!cancelled) setProvider(mockProvider);
|
|
37
|
-
}
|
|
38
|
-
if (!cancelled) setIsLoading(false);
|
|
37
|
+
if (typeof window !== 'undefined' && window.webln) {
|
|
38
|
+
availableRef.current = window.webln;
|
|
39
|
+
if (!cancelled) setIsAvailable(true);
|
|
40
|
+
} else if (mockProvider !== undefined && process.env.NODE_ENV !== 'production') {
|
|
41
|
+
availableRef.current = mockProvider;
|
|
42
|
+
if (!cancelled) setIsAvailable(true);
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
|
|
45
|
+
if (!cancelled) setIsLoading(false);
|
|
46
|
+
|
|
42
47
|
return () => {
|
|
43
48
|
cancelled = true;
|
|
44
49
|
};
|
|
45
50
|
}, [mockProvider]);
|
|
46
51
|
|
|
52
|
+
const connect = useCallback(async (): Promise<WebLNProviderInterface | null> => {
|
|
53
|
+
const available = availableRef.current;
|
|
54
|
+
if (!available) return null;
|
|
55
|
+
|
|
56
|
+
setError(null);
|
|
57
|
+
try {
|
|
58
|
+
await available.enable();
|
|
59
|
+
setProvider(available);
|
|
60
|
+
return available;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const connectError =
|
|
63
|
+
err instanceof Error ? err : new Error('Failed to enable WebLN');
|
|
64
|
+
setError(connectError);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
47
69
|
return (
|
|
48
|
-
<WebLNContext.Provider
|
|
70
|
+
<WebLNContext.Provider
|
|
71
|
+
value={{ provider, isAvailable, isLoading, error, connect }}
|
|
72
|
+
>
|
|
49
73
|
{children}
|
|
50
74
|
</WebLNContext.Provider>
|
|
51
75
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useCallback,
|
|
3
|
+
import { useCallback, useState } from 'react';
|
|
4
4
|
import { PayButton } from '../webln/PayButton';
|
|
5
5
|
import { formatSats } from '../../lib/payment-history';
|
|
6
6
|
|
|
@@ -14,7 +14,6 @@ type TInvoiceResponse = {
|
|
|
14
14
|
invoice: string;
|
|
15
15
|
amountSats: number;
|
|
16
16
|
memo: string;
|
|
17
|
-
devPreimage?: string;
|
|
18
17
|
};
|
|
19
18
|
|
|
20
19
|
interface IPaymentGateProps {
|
|
@@ -71,26 +70,6 @@ export function PaymentGate({
|
|
|
71
70
|
return data;
|
|
72
71
|
}, [amountSats, memo, onError]);
|
|
73
72
|
|
|
74
|
-
useEffect(() => {
|
|
75
|
-
if (
|
|
76
|
-
process.env.NODE_ENV !== 'development' ||
|
|
77
|
-
!invoiceData?.devPreimage ||
|
|
78
|
-
step !== 'ready'
|
|
79
|
-
) {
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const timer = window.setTimeout(() => {
|
|
84
|
-
onPaymentConfirmed({
|
|
85
|
-
paymentId: invoiceData.paymentId,
|
|
86
|
-
preimage: invoiceData.devPreimage!,
|
|
87
|
-
});
|
|
88
|
-
reset();
|
|
89
|
-
}, 5000);
|
|
90
|
-
|
|
91
|
-
return () => window.clearTimeout(timer);
|
|
92
|
-
}, [invoiceData, step, onPaymentConfirmed, reset]);
|
|
93
|
-
|
|
94
73
|
async function handlePrepare() {
|
|
95
74
|
if (disabled || step === 'loading') return;
|
|
96
75
|
await requestInvoice();
|
|
@@ -143,12 +122,6 @@ export function PaymentGate({
|
|
|
143
122
|
/>
|
|
144
123
|
)}
|
|
145
124
|
|
|
146
|
-
{process.env.NODE_ENV === 'development' && step === 'ready' && (
|
|
147
|
-
<p className="text-center text-xs" style={{ color: 'var(--color-text-subtle)' }}>
|
|
148
|
-
Simulated payment in 5 seconds (dev only)
|
|
149
|
-
</p>
|
|
150
|
-
)}
|
|
151
|
-
|
|
152
125
|
{error && (
|
|
153
126
|
<p className="text-xs" style={{ color: 'var(--color-error, #ef4444)' }} role="alert">
|
|
154
127
|
{error}
|
|
@@ -45,7 +45,8 @@ Fedi also injects `window.fediInternal` (optional, versioned) for app-discovery
|
|
|
45
45
|
2. **Never install NIP-07 browser extension adapters.** `window.nostr` is already there.
|
|
46
46
|
3. **Always guard injected APIs with `typeof window.X !== 'undefined'`** before calling them — SSR and non-Fedi browsers will not have them.
|
|
47
47
|
4. **This is an App Router project** — components that use browser APIs or React hooks must have `'use client'` at the top.
|
|
48
|
-
5. **
|
|
48
|
+
5. **WebLN requires explicit `connect()`** — call `connect()` from `useWebLN()` on user action before payments; do not call `window.webln.enable()` on page load.
|
|
49
|
+
6. **fediInternal v2 methods require user gesture** — never call `getInstalledMiniApps()` or `installMiniApp()` on mount; handle `manageInstalledMiniApps` denials with `isFediPermissionError()`.
|
|
49
50
|
|
|
50
51
|
## Links
|
|
51
52
|
|
|
@@ -72,6 +72,26 @@ await window.fediInternal.installMiniApp({
|
|
|
72
72
|
|
|
73
73
|
Triggers Fedi's native UI to add a Mini App to the user's home screen. Resolves when the user confirms (or rejects) the install prompt.
|
|
74
74
|
|
|
75
|
+
## Permissions (`manageInstalledMiniApps`)
|
|
76
|
+
|
|
77
|
+
Both `getInstalledMiniApps()` and `installMiniApp()` require Fedi's **`manageInstalledMiniApps`** permission. Fedi prompts on first call. **Never invoke these methods on page load** — only after a user taps a button.
|
|
78
|
+
|
|
79
|
+
Detect permission denials with `isFediPermissionError()` from `lib/fedi.ts`:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { isFediPermissionError } from '../lib/fedi';
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await getInstalledMiniApps!();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (isFediPermissionError(err)) {
|
|
88
|
+
// Show ManageMiniAppsPermissionHint + manual retry
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
If the user denied with "Remember my choice", they must reset the permission in Fedi mini app settings before retrying.
|
|
94
|
+
|
|
75
95
|
## useFediInternal() hook
|
|
76
96
|
|
|
77
97
|
```ts
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import Link from 'next/link';
|
|
4
|
-
import { useState } from 'react';
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
5
|
import { BalanceDisplay } from '../../../components/fedi/BalanceDisplay';
|
|
6
6
|
import { FediVersionBadge } from '../../../components/fedi/FediVersionBadge';
|
|
7
|
+
import type { IInstallMiniAppProps } from '../../../components/fedi/InstallMiniAppButton';
|
|
7
8
|
|
|
8
|
-
const
|
|
9
|
+
const DEFAULT_INSTALL_APP: IInstallMiniAppProps = {
|
|
9
10
|
id: 'com.create-fedi-app.demo',
|
|
10
|
-
title: 'Fedi Demo App',
|
|
11
|
+
title: process.env.NEXT_PUBLIC_APP_NAME ?? 'Fedi Demo App',
|
|
11
12
|
url: 'https://example.com',
|
|
12
13
|
description: 'Sample mini app entry for the ecash-balance demo.',
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
export default function EcashDemoPage() {
|
|
16
17
|
const [howItWorksOpen, setHowItWorksOpen] = useState(false);
|
|
18
|
+
const [installApp, setInstallApp] = useState<IInstallMiniAppProps>(DEFAULT_INSTALL_APP);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
setInstallApp((prev) => ({ ...prev, url: window.location.origin }));
|
|
22
|
+
}, []);
|
|
17
23
|
|
|
18
24
|
return (
|
|
19
25
|
<div className="min-h-dvh bg-[var(--color-bg)] font-[family-name:var(--font-body)] text-[var(--color-text)]">
|
|
@@ -49,11 +55,14 @@ export default function EcashDemoPage() {
|
|
|
49
55
|
Environment & installed apps
|
|
50
56
|
</h2>
|
|
51
57
|
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
52
|
-
On fediInternal v2,
|
|
53
|
-
|
|
58
|
+
On fediInternal v2, tap <strong className="text-[var(--color-text)]">Load installed apps</strong>{' '}
|
|
59
|
+
to list mini app URLs or use{' '}
|
|
60
|
+
<code className="font-mono text-xs">installMiniApp()</code> to trigger Fedi's
|
|
61
|
+
native install sheet. Both require the user to grant{' '}
|
|
62
|
+
<code className="font-mono text-xs">manageInstalledMiniApps</code> when Fedi prompts.
|
|
54
63
|
</p>
|
|
55
64
|
</div>
|
|
56
|
-
<BalanceDisplay installApp={
|
|
65
|
+
<BalanceDisplay installApp={installApp} />
|
|
57
66
|
</section>
|
|
58
67
|
|
|
59
68
|
<section className="space-y-3">
|
|
@@ -100,6 +109,14 @@ export default function EcashDemoPage() {
|
|
|
100
109
|
<code className="font-mono text-xs">undefined</code>. Show an "Open in
|
|
101
110
|
Fedi" prompt instead of failing silently.
|
|
102
111
|
</p>
|
|
112
|
+
<p>
|
|
113
|
+
<code className="font-mono text-xs">getInstalledMiniApps()</code> and{' '}
|
|
114
|
+
<code className="font-mono text-xs">installMiniApp()</code> require Fedi's{' '}
|
|
115
|
+
<code className="font-mono text-xs">manageInstalledMiniApps</code> permission. Call
|
|
116
|
+
them only after a user taps a button — Fedi shows Allow/Deny on first use. If the
|
|
117
|
+
user denies with "Remember my choice", reset the permission in Fedi mini
|
|
118
|
+
app settings before retrying.
|
|
119
|
+
</p>
|
|
103
120
|
<p>
|
|
104
121
|
Use <code className="font-mono text-xs">installMiniApp()</code> to suggest
|
|
105
122
|
companion apps to your users. Fedi shows a native confirmation sheet; your mini app
|