create-fedi-app 0.1.0

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.
Files changed (133) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.js +11113 -0
  3. package/dist/templates/base/.env.example +5 -0
  4. package/dist/templates/base/app/demo/page.tsx +25 -0
  5. package/dist/templates/base/app/globals.css +95 -0
  6. package/dist/templates/base/app/layout.tsx +39 -0
  7. package/dist/templates/base/app/page.tsx +83 -0
  8. package/dist/templates/base/components/FediDevToolbar/FediDevToolbar.tsx +170 -0
  9. package/dist/templates/base/components/providers.tsx +63 -0
  10. package/dist/templates/base/env.ts +10 -0
  11. package/dist/templates/base/hooks/useFediInternal.ts +41 -0
  12. package/dist/templates/base/lib/fedi-types.ts +96 -0
  13. package/dist/templates/base/lib/fedi.ts +18 -0
  14. package/dist/templates/base/lib/nostr/hooks.ts +52 -0
  15. package/dist/templates/base/lib/nostr/index.ts +9 -0
  16. package/dist/templates/base/lib/nostr/mock.ts +60 -0
  17. package/dist/templates/base/lib/nostr/provider.tsx +64 -0
  18. package/dist/templates/base/lib/utils.ts +3 -0
  19. package/dist/templates/base/lib/webln/hooks.ts +67 -0
  20. package/dist/templates/base/lib/webln/index.ts +12 -0
  21. package/dist/templates/base/lib/webln/mock.ts +96 -0
  22. package/dist/templates/base/lib/webln/provider.tsx +52 -0
  23. package/dist/templates/base/next.config.ts +3 -0
  24. package/dist/templates/base/package.json +40 -0
  25. package/dist/templates/base/proxy.ts +8 -0
  26. package/dist/templates/base/tsconfig.json +20 -0
  27. package/dist/templates/base/vitest.config.ts +6 -0
  28. package/dist/templates/base/vitest.setup.ts +40 -0
  29. package/dist/templates/modules/ai-assistant/app/api/assistant/route.ts +45 -0
  30. package/dist/templates/modules/ai-assistant/app/demo/assistant/AssistantDemoClient.tsx +70 -0
  31. package/dist/templates/modules/ai-assistant/app/demo/assistant/page.tsx +23 -0
  32. package/dist/templates/modules/ai-assistant/components/ai/Assistant.tsx +220 -0
  33. package/dist/templates/modules/ai-assistant/components/ai/AssistantProvider.tsx +71 -0
  34. package/dist/templates/modules/ai-assistant/lib/ai/providers.ts +49 -0
  35. package/dist/templates/modules/ai-assistant/module.json +48 -0
  36. package/dist/templates/modules/ai-chat-gated/app/api/chat/invoice/route.ts +15 -0
  37. package/dist/templates/modules/ai-chat-gated/app/api/chat/route.ts +57 -0
  38. package/dist/templates/modules/ai-chat-gated/app/demo/ai-chat/page.tsx +58 -0
  39. package/dist/templates/modules/ai-chat-gated/components/ai/ChatMessage.tsx +50 -0
  40. package/dist/templates/modules/ai-chat-gated/components/ai/GatedChat.tsx +181 -0
  41. package/dist/templates/modules/ai-chat-gated/components/ai/PaymentGate.tsx +168 -0
  42. package/dist/templates/modules/ai-chat-gated/lib/ai/providers.ts +49 -0
  43. package/dist/templates/modules/ai-chat-gated/lib/chat-payment.ts +161 -0
  44. package/dist/templates/modules/ai-chat-gated/module.json +62 -0
  45. package/dist/templates/modules/ai-rules/.cursorrules +8 -0
  46. package/dist/templates/modules/ai-rules/.github/copilot-instructions.md +8 -0
  47. package/dist/templates/modules/ai-rules/CLAUDE.md +8 -0
  48. package/dist/templates/modules/ai-rules/module.json +20 -0
  49. package/dist/templates/modules/ai-rules/rules/OVERVIEW.md +56 -0
  50. package/dist/templates/modules/ai-rules/rules/architecture.md +108 -0
  51. package/dist/templates/modules/ai-rules/rules/design-system.md +94 -0
  52. package/dist/templates/modules/ai-rules/rules/fedi-api.md +120 -0
  53. package/dist/templates/modules/ai-rules/rules/nostr.md +232 -0
  54. package/dist/templates/modules/ai-rules/rules/patterns.md +408 -0
  55. package/dist/templates/modules/ai-rules/rules/testing.md +238 -0
  56. package/dist/templates/modules/ai-rules/rules/webln.md +241 -0
  57. package/dist/templates/modules/database/drizzle/supabase/0000_initial.sql +7 -0
  58. package/dist/templates/modules/database/drizzle/supabase/meta/_journal.json +13 -0
  59. package/dist/templates/modules/database/drizzle/turso/0000_initial.sql +7 -0
  60. package/dist/templates/modules/database/drizzle/turso/meta/_journal.json +13 -0
  61. package/dist/templates/modules/database/drizzle.config.supabase.ts +10 -0
  62. package/dist/templates/modules/database/drizzle.config.turso.ts +11 -0
  63. package/dist/templates/modules/database/env.supabase.ts +24 -0
  64. package/dist/templates/modules/database/env.turso.ts +23 -0
  65. package/dist/templates/modules/database/lib/db/index.supabase.ts +19 -0
  66. package/dist/templates/modules/database/lib/db/index.turso.ts +20 -0
  67. package/dist/templates/modules/database/lib/db/schema.supabase.ts +13 -0
  68. package/dist/templates/modules/database/lib/db/schema.turso.ts +13 -0
  69. package/dist/templates/modules/database/module.json +110 -0
  70. package/dist/templates/modules/ecash-balance/app/demo/ecash/page.tsx +115 -0
  71. package/dist/templates/modules/ecash-balance/components/fedi/BalanceDisplay.tsx +162 -0
  72. package/dist/templates/modules/ecash-balance/components/fedi/FediVersionBadge.tsx +39 -0
  73. package/dist/templates/modules/ecash-balance/components/fedi/InstallMiniAppButton.tsx +74 -0
  74. package/dist/templates/modules/ecash-balance/hooks/useFediBalance.ts +65 -0
  75. package/dist/templates/modules/ecash-balance/module.json +14 -0
  76. package/dist/templates/modules/lnurl/app/api/lnurlauth/route.ts +118 -0
  77. package/dist/templates/modules/lnurl/app/api/lnurlp/[username]/route.ts +70 -0
  78. package/dist/templates/modules/lnurl/app/api/lnurlw/route.ts +57 -0
  79. package/dist/templates/modules/lnurl/app/demo/lnurl/page.tsx +136 -0
  80. package/dist/templates/modules/lnurl/components/lnurl/LnurlAuth.tsx +156 -0
  81. package/dist/templates/modules/lnurl/components/lnurl/LnurlPay.tsx +36 -0
  82. package/dist/templates/modules/lnurl/components/lnurl/LnurlQR.tsx +96 -0
  83. package/dist/templates/modules/lnurl/components/lnurl/LnurlWithdraw.tsx +141 -0
  84. package/dist/templates/modules/lnurl/lib/lnurl-auth-verify.ts +87 -0
  85. package/dist/templates/modules/lnurl/lib/lnurl-store.ts +112 -0
  86. package/dist/templates/modules/lnurl/lib/lnurl-utils.ts +56 -0
  87. package/dist/templates/modules/lnurl/module.json +27 -0
  88. package/dist/templates/modules/multispend-demo/app/demo/multispend/MultispendDemoClient.tsx +109 -0
  89. package/dist/templates/modules/multispend-demo/app/demo/multispend/page.tsx +23 -0
  90. package/dist/templates/modules/multispend-demo/components/multispend/ApprovalVote.tsx +122 -0
  91. package/dist/templates/modules/multispend-demo/components/multispend/MultispendDemo.tsx +220 -0
  92. package/dist/templates/modules/multispend-demo/components/multispend/MultispendProposal.tsx +213 -0
  93. package/dist/templates/modules/multispend-demo/components/multispend/ProposalList.tsx +49 -0
  94. package/dist/templates/modules/multispend-demo/hooks/useMultispendDemo.ts +127 -0
  95. package/dist/templates/modules/multispend-demo/lib/multispend-types.ts +33 -0
  96. package/dist/templates/modules/multispend-demo/lib/multispend-utils.ts +69 -0
  97. package/dist/templates/modules/multispend-demo/module.json +18 -0
  98. package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/NostrFeedDemoClient.tsx +134 -0
  99. package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/page.tsx +23 -0
  100. package/dist/templates/modules/nostr-feed/components/nostr/NostrFeedProvider.tsx +47 -0
  101. package/dist/templates/modules/nostr-feed/components/nostr/NoteCard.tsx +68 -0
  102. package/dist/templates/modules/nostr-feed/components/nostr/NoteFeed.tsx +109 -0
  103. package/dist/templates/modules/nostr-feed/components/nostr/PublishNote.tsx +104 -0
  104. package/dist/templates/modules/nostr-feed/components/nostr/ZapButton.tsx +140 -0
  105. package/dist/templates/modules/nostr-feed/lib/nostr/relay.ts +107 -0
  106. package/dist/templates/modules/nostr-feed/lib/nostr-zap.ts +159 -0
  107. package/dist/templates/modules/nostr-feed/module.json +25 -0
  108. package/dist/templates/modules/nostr-identity/app/demo/nostr/page.tsx +136 -0
  109. package/dist/templates/modules/nostr-identity/components/nostr/IdentityBadge.tsx +109 -0
  110. package/dist/templates/modules/nostr-identity/components/nostr/NostrLogin.tsx +107 -0
  111. package/dist/templates/modules/nostr-identity/components/nostr/SignedMessage.tsx +103 -0
  112. package/dist/templates/modules/nostr-identity/hooks/useIdentityFlow.ts +61 -0
  113. package/dist/templates/modules/nostr-identity/lib/nostr-utils.ts +30 -0
  114. package/dist/templates/modules/nostr-identity/module.json +15 -0
  115. package/dist/templates/modules/payment-gated-content/app/api/payment-gate/invoice/route.ts +25 -0
  116. package/dist/templates/modules/payment-gated-content/app/api/payment-gate/verify/route.ts +39 -0
  117. package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/article/page.tsx +71 -0
  118. package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/page.tsx +134 -0
  119. package/dist/templates/modules/payment-gated-content/components/payment-gated/PayGate.tsx +267 -0
  120. package/dist/templates/modules/payment-gated-content/lib/payment-gate.ts +195 -0
  121. package/dist/templates/modules/payment-gated-content/lib/payment-store.ts +104 -0
  122. package/dist/templates/modules/payment-gated-content/module.json +24 -0
  123. package/dist/templates/modules/payment-gated-content/proxy.ts +27 -0
  124. package/dist/templates/modules/webln-payments/app/demo/webln/page.tsx +176 -0
  125. package/dist/templates/modules/webln-payments/components/webln/InvoiceCard.tsx +170 -0
  126. package/dist/templates/modules/webln-payments/components/webln/PayButton.tsx +92 -0
  127. package/dist/templates/modules/webln-payments/components/webln/PaymentHistory.tsx +102 -0
  128. package/dist/templates/modules/webln-payments/hooks/__tests__/usePaymentFlow.test.tsx +182 -0
  129. package/dist/templates/modules/webln-payments/hooks/usePaymentFlow.ts +100 -0
  130. package/dist/templates/modules/webln-payments/lib/payment-history.ts +75 -0
  131. package/dist/templates/modules/webln-payments/module.json +17 -0
  132. package/dist/templates/modules/webln-payments/tests/e2e/webln-payment.spec.ts +41 -0
  133. package/package.json +29 -0
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useEffect, useMemo, type ReactNode } from 'react';
4
+ import {
5
+ closeRelayManager,
6
+ getRelayManager,
7
+ resolveRelayUrls,
8
+ type RelayManager,
9
+ } from '../../lib/nostr/relay';
10
+
11
+ interface INostrFeedContext {
12
+ relayUrls: string[];
13
+ manager: RelayManager;
14
+ }
15
+
16
+ const NostrFeedContext = createContext<INostrFeedContext | null>(null);
17
+
18
+ interface INostrFeedProviderProps {
19
+ children: ReactNode;
20
+ relayUrls?: string[];
21
+ }
22
+
23
+ export function NostrFeedProvider({ children, relayUrls }: INostrFeedProviderProps) {
24
+ const urls = useMemo(
25
+ () => (relayUrls?.length ? relayUrls : resolveRelayUrls()),
26
+ [relayUrls],
27
+ );
28
+ const manager = useMemo(() => getRelayManager(), []);
29
+
30
+ useEffect(() => {
31
+ return () => closeRelayManager();
32
+ }, []);
33
+
34
+ return (
35
+ <NostrFeedContext.Provider value={{ relayUrls: urls, manager }}>
36
+ {children}
37
+ </NostrFeedContext.Provider>
38
+ );
39
+ }
40
+
41
+ export function useNostrFeed(): INostrFeedContext {
42
+ const ctx = useContext(NostrFeedContext);
43
+ if (!ctx) {
44
+ throw new Error('useNostrFeed must be used within NostrFeedProvider');
45
+ }
46
+ return ctx;
47
+ }
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+
3
+ import ReactMarkdown from 'react-markdown';
4
+ import { pubkeyToHsl, pubkeyToNpub, truncateNpub } from '../../lib/nostr-utils';
5
+ import type { NostrEvent } from '../../lib/fedi-types';
6
+ import { ZapButton } from './ZapButton';
7
+
8
+ interface INoteCardProps {
9
+ event: NostrEvent;
10
+ }
11
+
12
+ function formatNoteTime(createdAt: number): string {
13
+ const date = new Date(createdAt * 1000);
14
+ const now = Date.now();
15
+ const diffSec = Math.floor((now - date.getTime()) / 1000);
16
+
17
+ if (diffSec < 60) return 'just now';
18
+ if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
19
+ if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
20
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
21
+ }
22
+
23
+ /**
24
+ * Renders a single kind-1 note with Markdown-safe content and zap support.
25
+ */
26
+ export function NoteCard({ event }: INoteCardProps) {
27
+ const npub = pubkeyToNpub(event.pubkey);
28
+ const displayNpub = truncateNpub(npub);
29
+ const { h, s, l } = pubkeyToHsl(event.pubkey);
30
+
31
+ return (
32
+ <article
33
+ className="rounded-xl px-4 py-3"
34
+ style={{
35
+ background: 'var(--color-surface-1)',
36
+ border: '1px solid var(--color-border)',
37
+ borderRadius: 'var(--radius-lg)',
38
+ }}
39
+ >
40
+ <header className="mb-2 flex items-center justify-between gap-2">
41
+ <div className="flex min-w-0 items-center gap-2">
42
+ <span
43
+ className="h-8 w-8 shrink-0 rounded-full"
44
+ style={{ background: `hsl(${h} ${s}% ${l}%)` }}
45
+ aria-hidden
46
+ />
47
+ <div className="min-w-0">
48
+ <p className="truncate font-mono text-xs text-[var(--color-text)]">{displayNpub}</p>
49
+ <time
50
+ className="text-xs text-[var(--color-text-subtle)]"
51
+ dateTime={new Date(event.created_at * 1000).toISOString()}
52
+ >
53
+ {formatNoteTime(event.created_at)}
54
+ </time>
55
+ </div>
56
+ </div>
57
+ <ZapButton noteId={event.id} notePubkey={event.pubkey} />
58
+ </header>
59
+
60
+ <div
61
+ className="prose prose-invert prose-sm max-w-none text-sm leading-[1.65] prose-p:my-1 prose-pre:my-2 prose-pre:rounded-md prose-pre:bg-[var(--color-surface-2)] prose-code:text-[var(--color-accent)] prose-code:before:content-none prose-code:after:content-none"
62
+ style={{ color: 'var(--color-text-muted)' }}
63
+ >
64
+ <ReactMarkdown>{event.content || '…'}</ReactMarkdown>
65
+ </div>
66
+ </article>
67
+ );
68
+ }
@@ -0,0 +1,109 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import type { NostrEvent } from '../../lib/fedi-types';
5
+ import { useNostrFeed } from './NostrFeedProvider';
6
+ import { NoteCard } from './NoteCard';
7
+
8
+ const FEED_LIMIT = 50;
9
+
10
+ /**
11
+ * Subscribes to kind-1 text notes from configured relays and renders a scrollable feed.
12
+ */
13
+ export function NoteFeed() {
14
+ const { relayUrls, manager } = useNostrFeed();
15
+ const [notes, setNotes] = useState<NostrEvent[]>([]);
16
+ const [status, setStatus] = useState<'loading' | 'live' | 'error'>('loading');
17
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
18
+
19
+ const upsertNote = useCallback((event: NostrEvent) => {
20
+ setNotes((prev) => {
21
+ if (prev.some((n) => n.id === event.id)) return prev;
22
+ const next = [event, ...prev];
23
+ next.sort((a, b) => b.created_at - a.created_at);
24
+ return next.slice(0, FEED_LIMIT);
25
+ });
26
+ }, []);
27
+
28
+ useEffect(() => {
29
+ let cancelled = false;
30
+ setStatus('loading');
31
+ setErrorMessage(null);
32
+
33
+ const since = Math.floor(Date.now() / 1000) - 60 * 60 * 24;
34
+
35
+ manager
36
+ .query(relayUrls, { kinds: [1], limit: FEED_LIMIT, since }, 8000)
37
+ .then((initial) => {
38
+ if (cancelled) return;
39
+ const sorted = [...initial].sort((a, b) => b.created_at - a.created_at);
40
+ setNotes(sorted.slice(0, FEED_LIMIT));
41
+ setStatus('live');
42
+ })
43
+ .catch((err) => {
44
+ if (cancelled) return;
45
+ setStatus('error');
46
+ setErrorMessage(err instanceof Error ? err.message : 'Failed to load feed');
47
+ });
48
+
49
+ const unsubscribe = manager.subscribe(
50
+ relayUrls,
51
+ { kinds: [1], since },
52
+ {
53
+ onEvent: upsertNote,
54
+ onEose: () => {
55
+ if (!cancelled) setStatus((s) => (s === 'loading' ? 'live' : s));
56
+ },
57
+ },
58
+ );
59
+
60
+ return () => {
61
+ cancelled = true;
62
+ unsubscribe();
63
+ };
64
+ }, [manager, relayUrls, upsertNote]);
65
+
66
+ if (status === 'error') {
67
+ return (
68
+ <p className="text-sm text-[var(--color-error,#ef4444)]" role="alert">
69
+ {errorMessage ?? 'Could not connect to relays'}
70
+ </p>
71
+ );
72
+ }
73
+
74
+ return (
75
+ <div className="flex flex-col gap-3">
76
+ <div className="flex items-center justify-between gap-2">
77
+ <p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-subtle)]">
78
+ Feed
79
+ </p>
80
+ <span
81
+ className="font-mono text-xs text-[var(--color-text-subtle)]"
82
+ aria-live="polite"
83
+ >
84
+ {status === 'loading' ? 'Connecting…' : `${notes.length} notes`}
85
+ </span>
86
+ </div>
87
+
88
+ <div
89
+ className="flex max-h-[min(60dvh,28rem)] flex-col gap-3 overflow-y-auto overscroll-contain pr-1"
90
+ aria-busy={status === 'loading'}
91
+ aria-label="Nostr note feed"
92
+ >
93
+ {notes.length === 0 && status === 'loading' && (
94
+ <p className="text-sm text-[var(--color-text-muted)]">Loading notes from relays…</p>
95
+ )}
96
+
97
+ {notes.length === 0 && status === 'live' && (
98
+ <p className="text-sm text-[var(--color-text-muted)]">
99
+ No notes yet. Publish one below. It may take a few seconds to appear.
100
+ </p>
101
+ )}
102
+
103
+ {notes.map((note) => (
104
+ <NoteCard key={note.id} event={note} />
105
+ ))}
106
+ </div>
107
+ </div>
108
+ );
109
+ }
@@ -0,0 +1,104 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useIdentity } from '../../lib/nostr';
5
+ import { useNostrFeed } from './NostrFeedProvider';
6
+
7
+ /**
8
+ * Text input and publish button: signs a kind-1 note and sends it to relays.
9
+ */
10
+ export function PublishNote() {
11
+ const { relayUrls, manager } = useNostrFeed();
12
+ const { pubkey, signEvent, isConnecting } = useIdentity();
13
+ const [content, setContent] = useState('');
14
+ const [isPublishing, setIsPublishing] = useState(false);
15
+ const [publishError, setPublishError] = useState<string | null>(null);
16
+ const [lastPublishedId, setLastPublishedId] = useState<string | null>(null);
17
+
18
+ async function handlePublish() {
19
+ const text = content.trim();
20
+ if (!text || !pubkey) return;
21
+
22
+ setIsPublishing(true);
23
+ setPublishError(null);
24
+
25
+ try {
26
+ const signed = await signEvent({
27
+ kind: 1,
28
+ content: text,
29
+ tags: [],
30
+ created_at: Math.floor(Date.now() / 1000),
31
+ });
32
+
33
+ if (!signed) {
34
+ throw new Error('Connect your Nostr identity to publish');
35
+ }
36
+
37
+ await manager.publish(relayUrls, signed);
38
+ setLastPublishedId(signed.id);
39
+ setContent('');
40
+ } catch (err) {
41
+ setPublishError(err instanceof Error ? err.message : 'Publish failed');
42
+ } finally {
43
+ setIsPublishing(false);
44
+ }
45
+ }
46
+
47
+ if (!pubkey) {
48
+ return (
49
+ <p className="text-sm text-[var(--color-text-subtle)]">
50
+ Connect your Nostr identity (NIP-07) to publish notes to the relay.
51
+ </p>
52
+ );
53
+ }
54
+
55
+ return (
56
+ <div className="flex flex-col gap-3">
57
+ <label className="flex flex-col gap-1.5">
58
+ <span className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-subtle)]">
59
+ New note
60
+ </span>
61
+ <textarea
62
+ value={content}
63
+ onChange={(e) => setContent(e.target.value)}
64
+ rows={3}
65
+ placeholder="What's happening?"
66
+ className="w-full resize-none rounded-lg px-3 py-2 text-sm"
67
+ style={{
68
+ background: 'var(--color-surface-1)',
69
+ border: '1px solid var(--color-border)',
70
+ color: 'var(--color-text)',
71
+ borderRadius: 'var(--radius-md)',
72
+ }}
73
+ />
74
+ </label>
75
+
76
+ <button
77
+ type="button"
78
+ onClick={handlePublish}
79
+ disabled={!content.trim() || isPublishing || isConnecting}
80
+ className="self-start rounded-lg px-4 py-2 text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70 disabled:cursor-not-allowed disabled:opacity-40"
81
+ style={{
82
+ background: 'var(--color-accent)',
83
+ color: 'var(--color-primary-foreground)',
84
+ borderRadius: 'var(--radius-md)',
85
+ }}
86
+ aria-label="Publish note to Nostr relays"
87
+ >
88
+ {isPublishing ? 'Publishing…' : 'Publish'}
89
+ </button>
90
+
91
+ {publishError && (
92
+ <p className="text-xs text-[var(--color-error,#ef4444)]" role="alert">
93
+ {publishError}
94
+ </p>
95
+ )}
96
+
97
+ {lastPublishedId && !publishError && (
98
+ <p className="text-xs text-[var(--color-text-subtle)]" role="status">
99
+ Published. Waiting for relays to echo your note.
100
+ </p>
101
+ )}
102
+ </div>
103
+ );
104
+ }
@@ -0,0 +1,140 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useIdentity } from '../../lib/nostr';
5
+ import { usePayment } from '../../lib/webln';
6
+ import {
7
+ buildZapReceipt,
8
+ buildZapRequest,
9
+ fetchLnurlPayMetadata,
10
+ fetchRecipientLnurl,
11
+ requestZapInvoice,
12
+ } from '../../lib/nostr-zap';
13
+ import { useNostrFeed } from './NostrFeedProvider';
14
+
15
+ const DEFAULT_ZAP_SATS = 21;
16
+
17
+ interface IZapButtonProps {
18
+ noteId: string;
19
+ notePubkey: string;
20
+ zapSats?: number;
21
+ }
22
+
23
+ /**
24
+ * NIP-57 zap: LNURL invoice from the recipient's Lightning address, WebLN payment, zap receipt on relay.
25
+ */
26
+ export function ZapButton({ noteId, notePubkey, zapSats = DEFAULT_ZAP_SATS }: IZapButtonProps) {
27
+ const { relayUrls, manager } = useNostrFeed();
28
+ const { pubkey, signEvent } = useIdentity();
29
+ const { sendPayment, isPaying } = usePayment();
30
+ const [status, setStatus] = useState<'idle' | 'zapping' | 'zapped' | 'error'>('idle');
31
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
32
+
33
+ async function handleZap() {
34
+ if (!pubkey) {
35
+ setStatus('error');
36
+ setErrorMessage('Connect Nostr identity to zap');
37
+ return;
38
+ }
39
+
40
+ setStatus('zapping');
41
+ setErrorMessage(null);
42
+
43
+ try {
44
+ const lnurl = await fetchRecipientLnurl(manager, relayUrls, notePubkey);
45
+ if (!lnurl) {
46
+ throw new Error('Recipient has no Lightning address on their profile');
47
+ }
48
+
49
+ const metadata = await fetchLnurlPayMetadata(lnurl);
50
+ const amountMsats = zapSats * 1000;
51
+
52
+ if (amountMsats < metadata.minSendable || amountMsats > metadata.maxSendable) {
53
+ throw new Error(
54
+ `Zap amount must be between ${Math.ceil(metadata.minSendable / 1000)} and ${Math.floor(metadata.maxSendable / 1000)} sats`,
55
+ );
56
+ }
57
+
58
+ const zapRequest = buildZapRequest({
59
+ noteId,
60
+ notePubkey,
61
+ relayUrls,
62
+ amountMsats,
63
+ });
64
+
65
+ const signedRequest = await signEvent(zapRequest);
66
+ if (!signedRequest) {
67
+ throw new Error('Failed to sign zap request');
68
+ }
69
+
70
+ const invoice = await requestZapInvoice(metadata.callback, amountMsats, signedRequest);
71
+ const payment = await sendPayment(invoice);
72
+
73
+ if (!payment?.preimage) {
74
+ throw new Error('Payment was not completed');
75
+ }
76
+
77
+ const receipt = buildZapReceipt({
78
+ noteId,
79
+ notePubkey,
80
+ payerPubkey: pubkey,
81
+ bolt11: invoice,
82
+ preimage: payment.preimage,
83
+ zapRequest: signedRequest,
84
+ });
85
+
86
+ const signedReceipt = await signEvent(receipt);
87
+ if (signedReceipt) {
88
+ await manager.publish(relayUrls, signedReceipt);
89
+ }
90
+
91
+ setStatus('zapped');
92
+ } catch (err) {
93
+ setStatus('error');
94
+ setErrorMessage(err instanceof Error ? err.message : 'Zap failed');
95
+ }
96
+ }
97
+
98
+ if (status === 'zapped') {
99
+ return (
100
+ <span
101
+ className="shrink-0 rounded-md px-2 py-1 text-xs font-semibold"
102
+ style={{
103
+ background: 'var(--color-accent-dim)',
104
+ color: 'var(--color-accent)',
105
+ }}
106
+ role="status"
107
+ >
108
+ Zapped {zapSats}⚡
109
+ </span>
110
+ );
111
+ }
112
+
113
+ return (
114
+ <div className="flex shrink-0 flex-col items-end gap-1">
115
+ <button
116
+ type="button"
117
+ onClick={handleZap}
118
+ disabled={isPaying || status === 'zapping'}
119
+ className="rounded-md px-2 py-1 text-xs font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70 disabled:cursor-not-allowed disabled:opacity-40"
120
+ style={{
121
+ background: 'var(--color-surface-2)',
122
+ border: '1px solid var(--color-border)',
123
+ color: 'var(--color-text)',
124
+ }}
125
+ aria-label={`Zap ${zapSats} sats`}
126
+ aria-busy={isPaying || status === 'zapping'}
127
+ >
128
+ {isPaying || status === 'zapping' ? '…' : `⚡ ${zapSats}`}
129
+ </button>
130
+ {status === 'error' && errorMessage && (
131
+ <p
132
+ className="max-w-[10rem] text-right text-[10px] leading-tight text-[var(--color-error,#ef4444)]"
133
+ role="alert"
134
+ >
135
+ {errorMessage}
136
+ </p>
137
+ )}
138
+ </div>
139
+ );
140
+ }
@@ -0,0 +1,107 @@
1
+ import type { Event } from 'nostr-tools/core';
2
+ import type { Filter } from 'nostr-tools/filter';
3
+ import { SimplePool } from 'nostr-tools/pool';
4
+ import type { NostrEvent } from '../fedi-types';
5
+
6
+ /** Default public relays for the live demo. */
7
+ export const DEFAULT_RELAYS = [
8
+ 'wss://relay.damus.io',
9
+ 'wss://relay.nostr.band',
10
+ ] as const;
11
+
12
+ /**
13
+ * Resolves relay URLs from `NEXT_PUBLIC_NOSTR_RELAY` (comma-separated) or defaults.
14
+ */
15
+ export function resolveRelayUrls(override?: string): string[] {
16
+ const raw = override ?? process.env.NEXT_PUBLIC_NOSTR_RELAY;
17
+ if (raw?.trim()) {
18
+ return raw
19
+ .split(',')
20
+ .map((url) => url.trim())
21
+ .filter((url) => url.startsWith('wss://') || url.startsWith('ws://'));
22
+ }
23
+ return [...DEFAULT_RELAYS];
24
+ }
25
+
26
+ export type TRelayConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
27
+
28
+ /**
29
+ * WebSocket relay connection manager backed by `nostr-tools` SimplePool.
30
+ * Handles subscribe, publish, and automatic reconnect with exponential backoff.
31
+ */
32
+ export class RelayManager {
33
+ private readonly pool: SimplePool;
34
+ private readonly trackedRelays = new Set<string>();
35
+
36
+ constructor() {
37
+ this.pool = new SimplePool({ enablePing: true, enableReconnect: true });
38
+ }
39
+
40
+ /** Tracks which relays the pool has opened for `close()`. */
41
+ private trackRelays(urls: string[]): void {
42
+ for (const url of urls) this.trackedRelays.add(url);
43
+ }
44
+
45
+ /**
46
+ * Subscribes to events across multiple relays. Returns an unsubscribe function.
47
+ */
48
+ subscribe(
49
+ relayUrls: string[],
50
+ filter: Filter,
51
+ handlers: {
52
+ onEvent: (event: NostrEvent) => void;
53
+ onEose?: () => void;
54
+ },
55
+ ): () => void {
56
+ this.trackRelays(relayUrls);
57
+ const sub = this.pool.subscribe(relayUrls, filter, {
58
+ onevent(event: Event) {
59
+ handlers.onEvent(event as NostrEvent);
60
+ },
61
+ oneose: handlers.onEose,
62
+ });
63
+ return () => sub.close();
64
+ }
65
+
66
+ /**
67
+ * Publishes a signed event to all write relays. Resolves when at least one accepts.
68
+ */
69
+ async publish(relayUrls: string[], event: NostrEvent): Promise<void> {
70
+ this.trackRelays(relayUrls);
71
+ await Promise.any(this.pool.publish(relayUrls, event as Event));
72
+ }
73
+
74
+ /**
75
+ * One-shot query: collects events until EOSE or timeout.
76
+ */
77
+ async query(
78
+ relayUrls: string[],
79
+ filter: Filter,
80
+ timeoutMs = 5000,
81
+ ): Promise<NostrEvent[]> {
82
+ this.trackRelays(relayUrls);
83
+ const events = await this.pool.querySync(relayUrls, filter, { maxWait: timeoutMs });
84
+ return events as NostrEvent[];
85
+ }
86
+
87
+ /** Closes all relay connections opened by this manager. */
88
+ close(): void {
89
+ this.pool.close([...this.trackedRelays]);
90
+ this.trackedRelays.clear();
91
+ }
92
+ }
93
+
94
+ let sharedManager: RelayManager | null = null;
95
+
96
+ /** Singleton relay manager for client components in the same session. */
97
+ export function getRelayManager(): RelayManager {
98
+ if (!sharedManager) {
99
+ sharedManager = new RelayManager();
100
+ }
101
+ return sharedManager;
102
+ }
103
+
104
+ export function closeRelayManager(): void {
105
+ sharedManager?.close();
106
+ sharedManager = null;
107
+ }