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.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +11113 -0
- package/dist/templates/base/.env.example +5 -0
- package/dist/templates/base/app/demo/page.tsx +25 -0
- package/dist/templates/base/app/globals.css +95 -0
- package/dist/templates/base/app/layout.tsx +39 -0
- package/dist/templates/base/app/page.tsx +83 -0
- package/dist/templates/base/components/FediDevToolbar/FediDevToolbar.tsx +170 -0
- package/dist/templates/base/components/providers.tsx +63 -0
- package/dist/templates/base/env.ts +10 -0
- package/dist/templates/base/hooks/useFediInternal.ts +41 -0
- package/dist/templates/base/lib/fedi-types.ts +96 -0
- package/dist/templates/base/lib/fedi.ts +18 -0
- package/dist/templates/base/lib/nostr/hooks.ts +52 -0
- package/dist/templates/base/lib/nostr/index.ts +9 -0
- package/dist/templates/base/lib/nostr/mock.ts +60 -0
- package/dist/templates/base/lib/nostr/provider.tsx +64 -0
- package/dist/templates/base/lib/utils.ts +3 -0
- package/dist/templates/base/lib/webln/hooks.ts +67 -0
- package/dist/templates/base/lib/webln/index.ts +12 -0
- package/dist/templates/base/lib/webln/mock.ts +96 -0
- package/dist/templates/base/lib/webln/provider.tsx +52 -0
- package/dist/templates/base/next.config.ts +3 -0
- package/dist/templates/base/package.json +40 -0
- package/dist/templates/base/proxy.ts +8 -0
- package/dist/templates/base/tsconfig.json +20 -0
- package/dist/templates/base/vitest.config.ts +6 -0
- package/dist/templates/base/vitest.setup.ts +40 -0
- package/dist/templates/modules/ai-assistant/app/api/assistant/route.ts +45 -0
- package/dist/templates/modules/ai-assistant/app/demo/assistant/AssistantDemoClient.tsx +70 -0
- package/dist/templates/modules/ai-assistant/app/demo/assistant/page.tsx +23 -0
- package/dist/templates/modules/ai-assistant/components/ai/Assistant.tsx +220 -0
- package/dist/templates/modules/ai-assistant/components/ai/AssistantProvider.tsx +71 -0
- package/dist/templates/modules/ai-assistant/lib/ai/providers.ts +49 -0
- package/dist/templates/modules/ai-assistant/module.json +48 -0
- package/dist/templates/modules/ai-chat-gated/app/api/chat/invoice/route.ts +15 -0
- package/dist/templates/modules/ai-chat-gated/app/api/chat/route.ts +57 -0
- package/dist/templates/modules/ai-chat-gated/app/demo/ai-chat/page.tsx +58 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/ChatMessage.tsx +50 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/GatedChat.tsx +181 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/PaymentGate.tsx +168 -0
- package/dist/templates/modules/ai-chat-gated/lib/ai/providers.ts +49 -0
- package/dist/templates/modules/ai-chat-gated/lib/chat-payment.ts +161 -0
- package/dist/templates/modules/ai-chat-gated/module.json +62 -0
- package/dist/templates/modules/ai-rules/.cursorrules +8 -0
- package/dist/templates/modules/ai-rules/.github/copilot-instructions.md +8 -0
- package/dist/templates/modules/ai-rules/CLAUDE.md +8 -0
- package/dist/templates/modules/ai-rules/module.json +20 -0
- package/dist/templates/modules/ai-rules/rules/OVERVIEW.md +56 -0
- package/dist/templates/modules/ai-rules/rules/architecture.md +108 -0
- package/dist/templates/modules/ai-rules/rules/design-system.md +94 -0
- package/dist/templates/modules/ai-rules/rules/fedi-api.md +120 -0
- package/dist/templates/modules/ai-rules/rules/nostr.md +232 -0
- package/dist/templates/modules/ai-rules/rules/patterns.md +408 -0
- package/dist/templates/modules/ai-rules/rules/testing.md +238 -0
- package/dist/templates/modules/ai-rules/rules/webln.md +241 -0
- package/dist/templates/modules/database/drizzle/supabase/0000_initial.sql +7 -0
- package/dist/templates/modules/database/drizzle/supabase/meta/_journal.json +13 -0
- package/dist/templates/modules/database/drizzle/turso/0000_initial.sql +7 -0
- package/dist/templates/modules/database/drizzle/turso/meta/_journal.json +13 -0
- package/dist/templates/modules/database/drizzle.config.supabase.ts +10 -0
- package/dist/templates/modules/database/drizzle.config.turso.ts +11 -0
- package/dist/templates/modules/database/env.supabase.ts +24 -0
- package/dist/templates/modules/database/env.turso.ts +23 -0
- package/dist/templates/modules/database/lib/db/index.supabase.ts +19 -0
- package/dist/templates/modules/database/lib/db/index.turso.ts +20 -0
- package/dist/templates/modules/database/lib/db/schema.supabase.ts +13 -0
- package/dist/templates/modules/database/lib/db/schema.turso.ts +13 -0
- package/dist/templates/modules/database/module.json +110 -0
- package/dist/templates/modules/ecash-balance/app/demo/ecash/page.tsx +115 -0
- package/dist/templates/modules/ecash-balance/components/fedi/BalanceDisplay.tsx +162 -0
- package/dist/templates/modules/ecash-balance/components/fedi/FediVersionBadge.tsx +39 -0
- package/dist/templates/modules/ecash-balance/components/fedi/InstallMiniAppButton.tsx +74 -0
- package/dist/templates/modules/ecash-balance/hooks/useFediBalance.ts +65 -0
- package/dist/templates/modules/ecash-balance/module.json +14 -0
- package/dist/templates/modules/lnurl/app/api/lnurlauth/route.ts +118 -0
- package/dist/templates/modules/lnurl/app/api/lnurlp/[username]/route.ts +70 -0
- package/dist/templates/modules/lnurl/app/api/lnurlw/route.ts +57 -0
- package/dist/templates/modules/lnurl/app/demo/lnurl/page.tsx +136 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlAuth.tsx +156 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlPay.tsx +36 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlQR.tsx +96 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlWithdraw.tsx +141 -0
- package/dist/templates/modules/lnurl/lib/lnurl-auth-verify.ts +87 -0
- package/dist/templates/modules/lnurl/lib/lnurl-store.ts +112 -0
- package/dist/templates/modules/lnurl/lib/lnurl-utils.ts +56 -0
- package/dist/templates/modules/lnurl/module.json +27 -0
- package/dist/templates/modules/multispend-demo/app/demo/multispend/MultispendDemoClient.tsx +109 -0
- package/dist/templates/modules/multispend-demo/app/demo/multispend/page.tsx +23 -0
- package/dist/templates/modules/multispend-demo/components/multispend/ApprovalVote.tsx +122 -0
- package/dist/templates/modules/multispend-demo/components/multispend/MultispendDemo.tsx +220 -0
- package/dist/templates/modules/multispend-demo/components/multispend/MultispendProposal.tsx +213 -0
- package/dist/templates/modules/multispend-demo/components/multispend/ProposalList.tsx +49 -0
- package/dist/templates/modules/multispend-demo/hooks/useMultispendDemo.ts +127 -0
- package/dist/templates/modules/multispend-demo/lib/multispend-types.ts +33 -0
- package/dist/templates/modules/multispend-demo/lib/multispend-utils.ts +69 -0
- package/dist/templates/modules/multispend-demo/module.json +18 -0
- package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/NostrFeedDemoClient.tsx +134 -0
- package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/page.tsx +23 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NostrFeedProvider.tsx +47 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NoteCard.tsx +68 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NoteFeed.tsx +109 -0
- package/dist/templates/modules/nostr-feed/components/nostr/PublishNote.tsx +104 -0
- package/dist/templates/modules/nostr-feed/components/nostr/ZapButton.tsx +140 -0
- package/dist/templates/modules/nostr-feed/lib/nostr/relay.ts +107 -0
- package/dist/templates/modules/nostr-feed/lib/nostr-zap.ts +159 -0
- package/dist/templates/modules/nostr-feed/module.json +25 -0
- package/dist/templates/modules/nostr-identity/app/demo/nostr/page.tsx +136 -0
- package/dist/templates/modules/nostr-identity/components/nostr/IdentityBadge.tsx +109 -0
- package/dist/templates/modules/nostr-identity/components/nostr/NostrLogin.tsx +107 -0
- package/dist/templates/modules/nostr-identity/components/nostr/SignedMessage.tsx +103 -0
- package/dist/templates/modules/nostr-identity/hooks/useIdentityFlow.ts +61 -0
- package/dist/templates/modules/nostr-identity/lib/nostr-utils.ts +30 -0
- package/dist/templates/modules/nostr-identity/module.json +15 -0
- package/dist/templates/modules/payment-gated-content/app/api/payment-gate/invoice/route.ts +25 -0
- package/dist/templates/modules/payment-gated-content/app/api/payment-gate/verify/route.ts +39 -0
- package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/article/page.tsx +71 -0
- package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/page.tsx +134 -0
- package/dist/templates/modules/payment-gated-content/components/payment-gated/PayGate.tsx +267 -0
- package/dist/templates/modules/payment-gated-content/lib/payment-gate.ts +195 -0
- package/dist/templates/modules/payment-gated-content/lib/payment-store.ts +104 -0
- package/dist/templates/modules/payment-gated-content/module.json +24 -0
- package/dist/templates/modules/payment-gated-content/proxy.ts +27 -0
- package/dist/templates/modules/webln-payments/app/demo/webln/page.tsx +176 -0
- package/dist/templates/modules/webln-payments/components/webln/InvoiceCard.tsx +170 -0
- package/dist/templates/modules/webln-payments/components/webln/PayButton.tsx +92 -0
- package/dist/templates/modules/webln-payments/components/webln/PaymentHistory.tsx +102 -0
- package/dist/templates/modules/webln-payments/hooks/__tests__/usePaymentFlow.test.tsx +182 -0
- package/dist/templates/modules/webln-payments/hooks/usePaymentFlow.ts +100 -0
- package/dist/templates/modules/webln-payments/lib/payment-history.ts +75 -0
- package/dist/templates/modules/webln-payments/module.json +17 -0
- package/dist/templates/modules/webln-payments/tests/e2e/webln-payment.spec.ts +41 -0
- package/package.json +29 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { formatSats } from '../../lib/fedi';
|
|
4
|
+
import { pubkeyToHsl, pubkeyToNpub, truncateNpub } from '../../lib/nostr-utils';
|
|
5
|
+
import type { NostrEvent } from '../../lib/nostr';
|
|
6
|
+
import type { TMultispendProposal, TVoteDecision } from '../../lib/multispend-types';
|
|
7
|
+
import { getApprovalCount, getMockVoterLabel } from '../../lib/multispend-utils';
|
|
8
|
+
import { ApprovalVote } from './ApprovalVote';
|
|
9
|
+
|
|
10
|
+
interface IMultispendProposalProps {
|
|
11
|
+
proposal: TMultispendProposal;
|
|
12
|
+
currentPubkey?: string | null;
|
|
13
|
+
onVote?: (vote: TVoteDecision, signedEvent: NostrEvent) => void;
|
|
14
|
+
onExecute?: () => void;
|
|
15
|
+
isExecuting?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const STATUS_LABELS: Record<TMultispendProposal['status'], string> = {
|
|
19
|
+
open: 'Awaiting votes',
|
|
20
|
+
approved: 'Threshold met',
|
|
21
|
+
rejected: 'Rejected',
|
|
22
|
+
executed: 'Executed',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function MultispendProposal({
|
|
26
|
+
proposal,
|
|
27
|
+
currentPubkey = null,
|
|
28
|
+
onVote,
|
|
29
|
+
onExecute,
|
|
30
|
+
isExecuting = false,
|
|
31
|
+
}: IMultispendProposalProps) {
|
|
32
|
+
const approvalCount = getApprovalCount(proposal);
|
|
33
|
+
const progress = Math.min(100, (approvalCount / proposal.threshold) * 100);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<article
|
|
37
|
+
className="space-y-4 rounded-xl p-4"
|
|
38
|
+
style={{
|
|
39
|
+
background: 'var(--color-surface-1)',
|
|
40
|
+
border: '1px solid var(--color-border)',
|
|
41
|
+
borderRadius: 'var(--radius-lg)',
|
|
42
|
+
}}
|
|
43
|
+
aria-label={`Spending proposal: ${proposal.description}`}
|
|
44
|
+
>
|
|
45
|
+
<header className="flex items-start justify-between gap-3">
|
|
46
|
+
<div className="min-w-0 space-y-1">
|
|
47
|
+
<p className="font-[family-name:var(--font-display)] text-lg font-semibold leading-tight text-[var(--color-text)]">
|
|
48
|
+
{formatSats(proposal.amountSats)}
|
|
49
|
+
</p>
|
|
50
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
51
|
+
{proposal.description}
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
<span
|
|
55
|
+
className="shrink-0 rounded px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
|
|
56
|
+
style={{
|
|
57
|
+
background:
|
|
58
|
+
proposal.status === 'executed'
|
|
59
|
+
? 'var(--color-accent-dim)'
|
|
60
|
+
: proposal.status === 'rejected'
|
|
61
|
+
? 'color-mix(in srgb, var(--color-error, #ef4444) 15%, transparent)'
|
|
62
|
+
: 'var(--color-surface-2, var(--color-bg))',
|
|
63
|
+
color:
|
|
64
|
+
proposal.status === 'executed'
|
|
65
|
+
? 'var(--color-accent)'
|
|
66
|
+
: proposal.status === 'rejected'
|
|
67
|
+
? 'var(--color-error, #ef4444)'
|
|
68
|
+
: 'var(--color-text-muted)',
|
|
69
|
+
border: '1px solid var(--color-border)',
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{STATUS_LABELS[proposal.status]}
|
|
73
|
+
</span>
|
|
74
|
+
</header>
|
|
75
|
+
|
|
76
|
+
<div className="space-y-2">
|
|
77
|
+
<div className="flex items-center justify-between text-xs">
|
|
78
|
+
<span className="font-semibold uppercase tracking-wider text-[var(--color-text-subtle)]">
|
|
79
|
+
Approvals
|
|
80
|
+
</span>
|
|
81
|
+
<span className="font-mono text-[var(--color-text-muted)]">
|
|
82
|
+
{approvalCount} / {proposal.threshold} required
|
|
83
|
+
</span>
|
|
84
|
+
</div>
|
|
85
|
+
<div
|
|
86
|
+
className="h-2 overflow-hidden rounded-full"
|
|
87
|
+
style={{ background: 'var(--color-surface-2, var(--color-bg))' }}
|
|
88
|
+
role="progressbar"
|
|
89
|
+
aria-valuenow={approvalCount}
|
|
90
|
+
aria-valuemin={0}
|
|
91
|
+
aria-valuemax={proposal.threshold}
|
|
92
|
+
aria-label={`${approvalCount} of ${proposal.threshold} approvals collected`}
|
|
93
|
+
>
|
|
94
|
+
<div
|
|
95
|
+
className="h-full rounded-full transition-[width] duration-300 ease-[cubic-bezier(0.25,1,0.5,1)]"
|
|
96
|
+
style={{
|
|
97
|
+
width: `${progress}%`,
|
|
98
|
+
background: 'var(--color-accent)',
|
|
99
|
+
}}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div className="space-y-2">
|
|
105
|
+
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-subtle)]">
|
|
106
|
+
Required signers
|
|
107
|
+
</p>
|
|
108
|
+
<ul className="space-y-2" aria-label="Required signers">
|
|
109
|
+
{proposal.requiredSigners.map((signerPubkey) => {
|
|
110
|
+
const approved = proposal.approvals.includes(signerPubkey);
|
|
111
|
+
const rejected = proposal.rejections.includes(signerPubkey);
|
|
112
|
+
const isYou = currentPubkey === signerPubkey;
|
|
113
|
+
const { h, s, l } = pubkeyToHsl(signerPubkey);
|
|
114
|
+
const label = isYou ? 'You' : getMockVoterLabel(signerPubkey);
|
|
115
|
+
const npub = truncateNpub(pubkeyToNpub(signerPubkey));
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<li
|
|
119
|
+
key={signerPubkey}
|
|
120
|
+
className="flex items-center gap-3 rounded-lg px-3 py-2"
|
|
121
|
+
style={{
|
|
122
|
+
background: 'var(--color-surface-2, var(--color-bg))',
|
|
123
|
+
border: '1px solid var(--color-border)',
|
|
124
|
+
borderRadius: 'var(--radius-md)',
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<span
|
|
128
|
+
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
|
129
|
+
style={{
|
|
130
|
+
background: `hsl(${h}, ${s}%, ${l}%)`,
|
|
131
|
+
color: 'var(--color-primary-foreground)',
|
|
132
|
+
}}
|
|
133
|
+
aria-hidden
|
|
134
|
+
>
|
|
135
|
+
{label.slice(0, 1)}
|
|
136
|
+
</span>
|
|
137
|
+
<div className="min-w-0 flex-1">
|
|
138
|
+
<p className="text-sm font-medium text-[var(--color-text)]">
|
|
139
|
+
{label}
|
|
140
|
+
{isYou && (
|
|
141
|
+
<span className="ml-1.5 text-xs font-normal text-[var(--color-text-muted)]">
|
|
142
|
+
(your key)
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
</p>
|
|
146
|
+
<p className="truncate font-mono text-xs text-[var(--color-text-subtle)]">{npub}</p>
|
|
147
|
+
</div>
|
|
148
|
+
<span
|
|
149
|
+
className="shrink-0 text-xs font-semibold"
|
|
150
|
+
style={{
|
|
151
|
+
color: approved
|
|
152
|
+
? 'var(--color-accent)'
|
|
153
|
+
: rejected
|
|
154
|
+
? 'var(--color-error, #ef4444)'
|
|
155
|
+
: 'var(--color-text-subtle)',
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{approved ? 'Approved' : rejected ? 'Rejected' : 'Pending'}
|
|
159
|
+
</span>
|
|
160
|
+
</li>
|
|
161
|
+
);
|
|
162
|
+
})}
|
|
163
|
+
</ul>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{proposal.status === 'open' &&
|
|
167
|
+
currentPubkey &&
|
|
168
|
+
proposal.requiredSigners.includes(currentPubkey) &&
|
|
169
|
+
!proposal.approvals.includes(currentPubkey) &&
|
|
170
|
+
!proposal.rejections.includes(currentPubkey) &&
|
|
171
|
+
onVote && (
|
|
172
|
+
<ApprovalVote
|
|
173
|
+
proposalId={proposal.id}
|
|
174
|
+
voterPubkey={currentPubkey}
|
|
175
|
+
currentPubkey={currentPubkey}
|
|
176
|
+
onVote={onVote}
|
|
177
|
+
/>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{proposal.status === 'approved' && onExecute && (
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
onClick={onExecute}
|
|
184
|
+
disabled={isExecuting}
|
|
185
|
+
className="w-full rounded-lg px-4 py-2.5 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"
|
|
186
|
+
style={{
|
|
187
|
+
background: 'var(--color-accent)',
|
|
188
|
+
color: 'var(--color-primary-foreground)',
|
|
189
|
+
borderRadius: 'var(--radius-md)',
|
|
190
|
+
}}
|
|
191
|
+
aria-busy={isExecuting}
|
|
192
|
+
>
|
|
193
|
+
{isExecuting ? 'Executing withdrawal…' : 'Simulate execution'}
|
|
194
|
+
</button>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{proposal.status === 'executed' && (
|
|
198
|
+
<p
|
|
199
|
+
className="rounded-lg px-3 py-2 text-xs leading-[1.65]"
|
|
200
|
+
style={{
|
|
201
|
+
background: 'var(--color-accent-dim)',
|
|
202
|
+
color: 'var(--color-accent)',
|
|
203
|
+
borderRadius: 'var(--radius-md)',
|
|
204
|
+
}}
|
|
205
|
+
role="status"
|
|
206
|
+
>
|
|
207
|
+
Withdrawal simulated. In Fedi, funds would move to the requester's wallet once the
|
|
208
|
+
threshold is met.
|
|
209
|
+
</p>
|
|
210
|
+
)}
|
|
211
|
+
</article>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { NostrEvent } from '../../lib/nostr';
|
|
4
|
+
import type { TMultispendProposal, TVoteDecision } from '../../lib/multispend-types';
|
|
5
|
+
import { MultispendProposal } from './MultispendProposal';
|
|
6
|
+
|
|
7
|
+
interface IProposalListProps {
|
|
8
|
+
proposals: TMultispendProposal[];
|
|
9
|
+
currentPubkey?: string | null;
|
|
10
|
+
onVote?: (proposalId: string, vote: TVoteDecision, signedEvent: NostrEvent) => void;
|
|
11
|
+
onExecute?: (proposalId: string) => void;
|
|
12
|
+
executingId?: string | null;
|
|
13
|
+
emptyMessage?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ProposalList({
|
|
17
|
+
proposals,
|
|
18
|
+
currentPubkey = null,
|
|
19
|
+
onVote,
|
|
20
|
+
onExecute,
|
|
21
|
+
executingId = null,
|
|
22
|
+
emptyMessage = 'No open proposals.',
|
|
23
|
+
}: IProposalListProps) {
|
|
24
|
+
if (proposals.length === 0) {
|
|
25
|
+
return (
|
|
26
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-subtle)]">{emptyMessage}</p>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<ul className="space-y-4" aria-label="Multispend proposals">
|
|
32
|
+
{proposals.map((proposal) => (
|
|
33
|
+
<li key={proposal.id}>
|
|
34
|
+
<MultispendProposal
|
|
35
|
+
proposal={proposal}
|
|
36
|
+
currentPubkey={currentPubkey}
|
|
37
|
+
onVote={
|
|
38
|
+
onVote
|
|
39
|
+
? (vote, signedEvent) => onVote(proposal.id, vote, signedEvent)
|
|
40
|
+
: undefined
|
|
41
|
+
}
|
|
42
|
+
onExecute={onExecute ? () => onExecute(proposal.id) : undefined}
|
|
43
|
+
isExecuting={executingId === proposal.id}
|
|
44
|
+
/>
|
|
45
|
+
</li>
|
|
46
|
+
))}
|
|
47
|
+
</ul>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from 'react';
|
|
4
|
+
import type { NostrEvent } from '../lib/nostr';
|
|
5
|
+
import {
|
|
6
|
+
createProposalId,
|
|
7
|
+
getInitialProposals,
|
|
8
|
+
hasVoted,
|
|
9
|
+
isThresholdMet,
|
|
10
|
+
MOCK_VOTERS,
|
|
11
|
+
} from '../lib/multispend-utils';
|
|
12
|
+
import type { TMultispendProposal, TMultispendVote, TProposalStatus, TVoteDecision } from '../lib/multispend-types';
|
|
13
|
+
|
|
14
|
+
export function useMultispendDemo() {
|
|
15
|
+
const [proposals, setProposals] = useState<TMultispendProposal[]>(() => getInitialProposals());
|
|
16
|
+
const [votes, setVotes] = useState<TMultispendVote[]>([]);
|
|
17
|
+
const [lastSignedEvent, setLastSignedEvent] = useState<NostrEvent | null>(null);
|
|
18
|
+
const [executingId, setExecutingId] = useState<string | null>(null);
|
|
19
|
+
|
|
20
|
+
const createProposal = useCallback(
|
|
21
|
+
(input: { amountSats: number; description: string; proposerPubkey: string }) => {
|
|
22
|
+
const signers = [
|
|
23
|
+
input.proposerPubkey,
|
|
24
|
+
...MOCK_VOTERS.map((v) => v.pubkey).filter((pk) => pk !== input.proposerPubkey),
|
|
25
|
+
].slice(0, 3);
|
|
26
|
+
|
|
27
|
+
const threshold = Math.min(2, signers.length);
|
|
28
|
+
const proposal: TMultispendProposal = {
|
|
29
|
+
id: createProposalId(),
|
|
30
|
+
amountSats: input.amountSats,
|
|
31
|
+
description: input.description.trim(),
|
|
32
|
+
requiredSigners: signers,
|
|
33
|
+
threshold,
|
|
34
|
+
approvals: [],
|
|
35
|
+
rejections: [],
|
|
36
|
+
status: 'open',
|
|
37
|
+
createdAt: Date.now(),
|
|
38
|
+
proposerPubkey: input.proposerPubkey,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
setProposals((current) => [proposal, ...current]);
|
|
42
|
+
return proposal;
|
|
43
|
+
},
|
|
44
|
+
[],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const recordVote = useCallback(
|
|
48
|
+
(proposalId: string, voterPubkey: string, vote: TVoteDecision, signedEvent: NostrEvent) => {
|
|
49
|
+
setLastSignedEvent(signedEvent);
|
|
50
|
+
setVotes((current) => [
|
|
51
|
+
...current.filter((v) => !(v.proposalId === proposalId && v.voterPubkey === voterPubkey)),
|
|
52
|
+
{ proposalId, voterPubkey, vote, signedEvent },
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
setProposals((current) =>
|
|
56
|
+
current.map((proposal) => {
|
|
57
|
+
if (proposal.id !== proposalId || proposal.status !== 'open') return proposal;
|
|
58
|
+
if (hasVoted(proposal, voterPubkey)) return proposal;
|
|
59
|
+
|
|
60
|
+
let approvals =
|
|
61
|
+
vote === 'approve'
|
|
62
|
+
? [...proposal.approvals, voterPubkey]
|
|
63
|
+
: proposal.approvals;
|
|
64
|
+
let rejections =
|
|
65
|
+
vote === 'reject'
|
|
66
|
+
? [...proposal.rejections, voterPubkey]
|
|
67
|
+
: proposal.rejections;
|
|
68
|
+
|
|
69
|
+
// Demo: mock co-voters auto-approve after a real Nostr signature
|
|
70
|
+
if (vote === 'approve' && approvals.length < proposal.threshold) {
|
|
71
|
+
const mockCoSigner = proposal.requiredSigners.find(
|
|
72
|
+
(pk) =>
|
|
73
|
+
pk !== voterPubkey &&
|
|
74
|
+
MOCK_VOTERS.some((m) => m.pubkey === pk) &&
|
|
75
|
+
!approvals.includes(pk) &&
|
|
76
|
+
!rejections.includes(pk),
|
|
77
|
+
);
|
|
78
|
+
if (mockCoSigner) {
|
|
79
|
+
approvals = [...approvals, mockCoSigner];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const allVoted =
|
|
84
|
+
approvals.length + rejections.length >= proposal.requiredSigners.length;
|
|
85
|
+
const rejected =
|
|
86
|
+
rejections.length > proposal.requiredSigners.length - proposal.threshold;
|
|
87
|
+
|
|
88
|
+
let status: TProposalStatus = proposal.status;
|
|
89
|
+
if (isThresholdMet({ ...proposal, approvals })) {
|
|
90
|
+
status = 'approved';
|
|
91
|
+
} else if (rejected || (allVoted && !isThresholdMet({ ...proposal, approvals }))) {
|
|
92
|
+
status = 'rejected';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { ...proposal, approvals, rejections, status };
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
[],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const simulateExecution = useCallback(async (proposalId: string) => {
|
|
103
|
+
setExecutingId(proposalId);
|
|
104
|
+
await new Promise((resolve) => window.setTimeout(resolve, 1200));
|
|
105
|
+
setProposals((current) =>
|
|
106
|
+
current.map((proposal) =>
|
|
107
|
+
proposal.id === proposalId && proposal.status === 'approved'
|
|
108
|
+
? { ...proposal, status: 'executed' }
|
|
109
|
+
: proposal,
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
setExecutingId(null);
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
const openProposals = proposals.filter((p) => p.status === 'open' || p.status === 'approved');
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
proposals,
|
|
119
|
+
openProposals,
|
|
120
|
+
votes,
|
|
121
|
+
lastSignedEvent,
|
|
122
|
+
executingId,
|
|
123
|
+
createProposal,
|
|
124
|
+
recordVote,
|
|
125
|
+
simulateExecution,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { NostrEvent } from '../lib/nostr';
|
|
2
|
+
|
|
3
|
+
export type TProposalStatus = 'open' | 'approved' | 'rejected' | 'executed';
|
|
4
|
+
|
|
5
|
+
export type TVoteDecision = 'approve' | 'reject';
|
|
6
|
+
|
|
7
|
+
/** Demo kind for Multispend approval votes signed via Nostr. */
|
|
8
|
+
export const MULTISPEND_VOTE_KIND = 38383;
|
|
9
|
+
|
|
10
|
+
export type TMultispendProposal = {
|
|
11
|
+
id: string;
|
|
12
|
+
amountSats: number;
|
|
13
|
+
description: string;
|
|
14
|
+
requiredSigners: string[];
|
|
15
|
+
threshold: number;
|
|
16
|
+
approvals: string[];
|
|
17
|
+
rejections: string[];
|
|
18
|
+
status: TProposalStatus;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
proposerPubkey: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type TMultispendVote = {
|
|
24
|
+
proposalId: string;
|
|
25
|
+
voterPubkey: string;
|
|
26
|
+
vote: TVoteDecision;
|
|
27
|
+
signedEvent: NostrEvent;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type TMockVoter = {
|
|
31
|
+
pubkey: string;
|
|
32
|
+
label: string;
|
|
33
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { TMockVoter, TMultispendProposal } from './multispend-types';
|
|
2
|
+
|
|
3
|
+
/** Mock voter pubkeys for the demo. Real Multispend uses Fedi group members. */
|
|
4
|
+
export const MOCK_VOTERS: TMockVoter[] = [
|
|
5
|
+
{
|
|
6
|
+
pubkey: '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798',
|
|
7
|
+
label: 'Alice',
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
pubkey: 'c6047f9441ed7d6d3045406e95e0aa2944fef797332e9cb0f5c85d8d280e47bd',
|
|
11
|
+
label: 'Bob',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
pubkey: 'f9308a3192592395af7d9b875b252fe1840d59a1363c88b9b5c6a7767632b87',
|
|
15
|
+
label: 'Carol',
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function getMockVoterLabel(pubkey: string): string {
|
|
20
|
+
const voter = MOCK_VOTERS.find((v) => v.pubkey === pubkey);
|
|
21
|
+
return voter?.label ?? 'Voter';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createProposalId(): string {
|
|
25
|
+
return `prop-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getApprovalCount(proposal: TMultispendProposal): number {
|
|
29
|
+
return proposal.approvals.length;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isThresholdMet(proposal: TMultispendProposal): boolean {
|
|
33
|
+
return proposal.approvals.length >= proposal.threshold;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function hasVoted(proposal: TMultispendProposal, pubkey: string): boolean {
|
|
37
|
+
return proposal.approvals.includes(pubkey) || proposal.rejections.includes(pubkey);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getInitialProposals(): TMultispendProposal[] {
|
|
41
|
+
const [alice, bob, carol] = MOCK_VOTERS;
|
|
42
|
+
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
id: 'prop-seed-lunch',
|
|
46
|
+
amountSats: 42_000,
|
|
47
|
+
description: 'Team lunch: reimburse catering for the sprint retro',
|
|
48
|
+
requiredSigners: [alice.pubkey, bob.pubkey, carol.pubkey],
|
|
49
|
+
threshold: 2,
|
|
50
|
+
approvals: [],
|
|
51
|
+
rejections: [],
|
|
52
|
+
status: 'open',
|
|
53
|
+
createdAt: Date.now() - 3_600_000,
|
|
54
|
+
proposerPubkey: alice.pubkey,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'prop-seed-supplies',
|
|
58
|
+
amountSats: 15_000,
|
|
59
|
+
description: 'Shared office supplies: printer paper and markers',
|
|
60
|
+
requiredSigners: [alice.pubkey, bob.pubkey, carol.pubkey],
|
|
61
|
+
threshold: 2,
|
|
62
|
+
approvals: [alice.pubkey, bob.pubkey],
|
|
63
|
+
rejections: [],
|
|
64
|
+
status: 'approved',
|
|
65
|
+
createdAt: Date.now() - 86_400_000,
|
|
66
|
+
proposerPubkey: bob.pubkey,
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "multispend-demo",
|
|
3
|
+
"description": "Fedi multispend shared wallet UI",
|
|
4
|
+
"dependencies": [],
|
|
5
|
+
"devDependencies": [],
|
|
6
|
+
"files": [
|
|
7
|
+
{ "src": "lib/multispend-types.ts", "dest": "lib/multispend-types.ts", "merge": "add" },
|
|
8
|
+
{ "src": "lib/multispend-utils.ts", "dest": "lib/multispend-utils.ts", "merge": "add" },
|
|
9
|
+
{ "src": "hooks/useMultispendDemo.ts", "dest": "hooks/useMultispendDemo.ts", "merge": "add" },
|
|
10
|
+
{ "src": "components/multispend/ApprovalVote.tsx", "dest": "components/multispend/ApprovalVote.tsx", "merge": "add" },
|
|
11
|
+
{ "src": "components/multispend/MultispendProposal.tsx", "dest": "components/multispend/MultispendProposal.tsx", "merge": "add" },
|
|
12
|
+
{ "src": "components/multispend/ProposalList.tsx", "dest": "components/multispend/ProposalList.tsx", "merge": "add" },
|
|
13
|
+
{ "src": "components/multispend/MultispendDemo.tsx", "dest": "components/multispend/MultispendDemo.tsx", "merge": "add" },
|
|
14
|
+
{ "src": "app/demo/multispend/page.tsx", "dest": "app/demo/multispend/page.tsx", "merge": "add" },
|
|
15
|
+
{ "src": "app/demo/multispend/MultispendDemoClient.tsx", "dest": "app/demo/multispend/MultispendDemoClient.tsx", "merge": "add" }
|
|
16
|
+
],
|
|
17
|
+
"envVars": []
|
|
18
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { NostrFeedProvider } from '../../../components/nostr/NostrFeedProvider';
|
|
6
|
+
import { NoteFeed } from '../../../components/nostr/NoteFeed';
|
|
7
|
+
import { PublishNote } from '../../../components/nostr/PublishNote';
|
|
8
|
+
import { DEFAULT_RELAYS } from '../../../lib/nostr/relay';
|
|
9
|
+
|
|
10
|
+
export function NostrFeedDemoClient() {
|
|
11
|
+
const [howItWorksOpen, setHowItWorksOpen] = useState(false);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="min-h-dvh bg-[var(--color-bg)] font-[family-name:var(--font-body)] text-[var(--color-text)]">
|
|
15
|
+
<div
|
|
16
|
+
className="mx-auto w-full max-w-[390px] px-4 pt-6"
|
|
17
|
+
style={{ paddingBottom: 'max(5rem, env(safe-area-inset-bottom, 20px))' }}
|
|
18
|
+
>
|
|
19
|
+
<Link
|
|
20
|
+
href="/demo"
|
|
21
|
+
className="mb-6 inline-block text-xs text-[var(--color-text-muted)] transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
|
|
22
|
+
>
|
|
23
|
+
← back
|
|
24
|
+
</Link>
|
|
25
|
+
|
|
26
|
+
<header className="mb-8 space-y-2">
|
|
27
|
+
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold leading-tight text-[var(--color-text)]">
|
|
28
|
+
Nostr feed
|
|
29
|
+
</h1>
|
|
30
|
+
<p className="max-w-[75ch] text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
31
|
+
Read and post kind-1 notes on public relays. Zaps combine WebLN payments with NIP-57
|
|
32
|
+
zap receipts published back to the relay.
|
|
33
|
+
</p>
|
|
34
|
+
</header>
|
|
35
|
+
|
|
36
|
+
<NostrFeedProvider>
|
|
37
|
+
<div className="space-y-8">
|
|
38
|
+
<section className="space-y-3 rounded-xl px-4 py-3 text-sm leading-[1.65]"
|
|
39
|
+
style={{
|
|
40
|
+
background: 'var(--color-surface-1)',
|
|
41
|
+
border: '1px solid var(--color-border)',
|
|
42
|
+
borderRadius: 'var(--radius-lg)',
|
|
43
|
+
color: 'var(--color-text-muted)',
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<p>
|
|
47
|
+
Connected to{' '}
|
|
48
|
+
<span className="font-mono text-xs text-[var(--color-text)]">
|
|
49
|
+
{DEFAULT_RELAYS.join(', ')}
|
|
50
|
+
</span>
|
|
51
|
+
. Override with{' '}
|
|
52
|
+
<code className="font-mono text-xs">NEXT_PUBLIC_NOSTR_RELAY</code> (comma-separated
|
|
53
|
+
URLs).
|
|
54
|
+
</p>
|
|
55
|
+
</section>
|
|
56
|
+
|
|
57
|
+
<section className="space-y-4">
|
|
58
|
+
<div className="max-w-[75ch] space-y-1.5">
|
|
59
|
+
<h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
|
|
60
|
+
Publish
|
|
61
|
+
</h2>
|
|
62
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
63
|
+
Signs a kind-1 text note with your NIP-07 key and publishes to relays.
|
|
64
|
+
</p>
|
|
65
|
+
</div>
|
|
66
|
+
<PublishNote />
|
|
67
|
+
</section>
|
|
68
|
+
|
|
69
|
+
<section className="space-y-4">
|
|
70
|
+
<div className="max-w-[75ch] space-y-1.5">
|
|
71
|
+
<h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
|
|
72
|
+
Live feed
|
|
73
|
+
</h2>
|
|
74
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
75
|
+
Subscribes to recent notes from the last 24 hours, then streams new events.
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
<NoteFeed />
|
|
79
|
+
</section>
|
|
80
|
+
|
|
81
|
+
<section className="space-y-3">
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
onClick={() => setHowItWorksOpen((open) => !open)}
|
|
85
|
+
className="flex w-full items-center justify-between rounded-lg px-4 py-3 text-left text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
|
|
86
|
+
style={{
|
|
87
|
+
background: 'var(--color-surface-1)',
|
|
88
|
+
border: '1px solid var(--color-border)',
|
|
89
|
+
color: 'var(--color-text)',
|
|
90
|
+
borderRadius: 'var(--radius-lg)',
|
|
91
|
+
}}
|
|
92
|
+
aria-expanded={howItWorksOpen}
|
|
93
|
+
aria-controls="nostr-feed-how-it-works"
|
|
94
|
+
>
|
|
95
|
+
How the feed works
|
|
96
|
+
<span aria-hidden>{howItWorksOpen ? '−' : '+'}</span>
|
|
97
|
+
</button>
|
|
98
|
+
|
|
99
|
+
{howItWorksOpen && (
|
|
100
|
+
<div
|
|
101
|
+
id="nostr-feed-how-it-works"
|
|
102
|
+
className="space-y-3 rounded-lg px-4 py-3 text-sm leading-[1.65]"
|
|
103
|
+
style={{
|
|
104
|
+
background: 'var(--color-surface-1)',
|
|
105
|
+
border: '1px solid var(--color-border)',
|
|
106
|
+
color: 'var(--color-text-muted)',
|
|
107
|
+
borderRadius: 'var(--radius-lg)',
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
<p>
|
|
111
|
+
<strong className="text-[var(--color-text)]">Relays</strong> are WebSocket
|
|
112
|
+
servers that store and forward Nostr events. This demo uses{' '}
|
|
113
|
+
<code className="font-mono text-xs">nostr-tools</code> with reconnect and
|
|
114
|
+
heartbeat pings.
|
|
115
|
+
</p>
|
|
116
|
+
<p>
|
|
117
|
+
<strong className="text-[var(--color-text)]">Zaps (NIP-57)</strong> look up the
|
|
118
|
+
author's <code className="font-mono text-xs">lud16</code> from their
|
|
119
|
+
profile, request a Lightning invoice with a signed zap request, pay via WebLN,
|
|
120
|
+
then publish a kind-9735 zap receipt.
|
|
121
|
+
</p>
|
|
122
|
+
<p>
|
|
123
|
+
Zaps only work when the note author has a Lightning address configured and
|
|
124
|
+
WebLN is available in Fedi.
|
|
125
|
+
</p>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</section>
|
|
129
|
+
</div>
|
|
130
|
+
</NostrFeedProvider>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { NostrFeedDemoClient } from './NostrFeedDemoClient';
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: 'Nostr feed',
|
|
6
|
+
description:
|
|
7
|
+
'Live Nostr social feed for Fedi mini apps. Read kind-1 notes from public relays, publish signed posts, and zap creators with WebLN and NIP-57.',
|
|
8
|
+
openGraph: {
|
|
9
|
+
title: 'Nostr feed',
|
|
10
|
+
description:
|
|
11
|
+
'Live Nostr social feed for Fedi mini apps. Read kind-1 notes from public relays, publish signed posts, and zap creators with WebLN and NIP-57.',
|
|
12
|
+
},
|
|
13
|
+
twitter: {
|
|
14
|
+
card: 'summary',
|
|
15
|
+
title: 'Nostr feed',
|
|
16
|
+
description:
|
|
17
|
+
'Live Nostr social feed for Fedi mini apps. Read kind-1 notes from public relays, publish signed posts, and zap creators with WebLN and NIP-57.',
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default function NostrFeedDemoPage() {
|
|
22
|
+
return <NostrFeedDemoClient />;
|
|
23
|
+
}
|