LightningMessageboardWidget.tsx raw
1 import {
2 Card,
3 CardContent,
4 CardFooter,
5 CardHeader,
6 CardTitle,
7 } from "src/components/ui/card";
8
9 import { NWCClient } from "@getalby/sdk/nwc";
10 import dayjs from "dayjs";
11 import { ChevronUpIcon, ZapIcon } from "lucide-react";
12 import React from "react";
13 import { toast } from "sonner";
14 import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
15 import Loading from "src/components/Loading";
16 import { Badge } from "src/components/ui/badge";
17 import { Button } from "src/components/ui/button";
18 import { LoadingButton } from "src/components/ui/custom/loading-button";
19 import {
20 Dialog,
21 DialogContent,
22 DialogDescription,
23 DialogFooter,
24 DialogHeader,
25 DialogTitle,
26 } from "src/components/ui/dialog";
27 import { Input } from "src/components/ui/input";
28 import { Label } from "src/components/ui/label";
29 import { Separator } from "src/components/ui/separator";
30 import { Textarea } from "src/components/ui/textarea";
31 import { PayInvoiceResponse } from "src/types";
32 import { request } from "src/utils/request";
33
34 // Must be a sub-wallet connection with only make invoice and list transactions permissions!
35 const LIGHTNING_MESSAGEBOARD_NWC_URL =
36 import.meta.env.VITE_LIGHTNING_MESSAGEBOARD_NWC_URL ||
37 "nostr+walletconnect://31758cb11d8060fa87ea955808dc22e3602aad7390717edd56dbbbd136c85a9b?relay=wss://relay.getalby.com&relay=wss://relay2.getalby.com&secret=dce4d879ca8d875b0dc38f98425829eff71a5d213db9d5d423bf284fa75efc80";
38
39 type Message = {
40 name?: string;
41 message: string;
42 amountSat: number;
43 created_at: number;
44 };
45
46 type TabType = "latest" | "top";
47
48 let nwcClient: NWCClient | undefined;
49 function getNWCClient(): NWCClient {
50 if (!nwcClient) {
51 nwcClient = new NWCClient({
52 nostrWalletConnectUrl: LIGHTNING_MESSAGEBOARD_NWC_URL,
53 });
54 }
55 return nwcClient;
56 }
57
58 function getSortedMessages(messages: Message[], tab: TabType): Message[] {
59 if (tab === "latest") {
60 return [...messages].sort((a, b) => b.created_at - a.created_at);
61 } else {
62 return [...messages].sort((a, b) => b.amountSat - a.amountSat);
63 }
64 }
65
66 export function LightningMessageboardWidget() {
67 const [messageText, setMessageText] = React.useState("");
68 const [senderName, setSenderName] = React.useState("");
69 const [amountSat, setAmountSat] = React.useState("");
70 const [messages, setMessages] = React.useState<Message[]>();
71 const [isLoading, setLoading] = React.useState(false);
72 const [isSubmitting, setSubmitting] = React.useState(false);
73 const [dialogOpen, setDialogOpen] = React.useState(false);
74 const [isOpen, setOpen] = React.useState(false);
75 const [currentTab, setCurrentTab] = React.useState<TabType>("latest");
76
77 const loadMessages = React.useCallback(() => {
78 (async () => {
79 setLoading(true);
80 let offset = 0;
81 const _messages: Message[] = [];
82 while (true) {
83 try {
84 const transactions = await getNWCClient().listTransactions({
85 offset,
86 limit: 10,
87 });
88
89 if (transactions.transactions.length === 0) {
90 break;
91 }
92
93 const newMessages = transactions.transactions.map((transaction) => ({
94 created_at: transaction.created_at,
95 message: transaction.description,
96 name: (
97 transaction.metadata as
98 | { payer_data?: { name?: string } }
99 | undefined
100 )?.payer_data?.name as string | undefined,
101 amountSat: Math.floor(transaction.amount / 1000),
102 }));
103
104 _messages.push(...newMessages);
105
106 // Update messages incrementally as they load
107 setMessages((prevMessages) => {
108 return [...(prevMessages || []), ...newMessages];
109 });
110
111 offset += transactions.transactions.length;
112 } catch (error) {
113 console.error(error);
114 await new Promise((resolve) => setTimeout(resolve, 1000));
115 }
116 }
117 setLoading(false);
118 })();
119 }, []);
120
121 const hasLoadedMessages = !!messages;
122
123 React.useEffect(() => {
124 if (isOpen && !hasLoadedMessages) {
125 loadMessages();
126 }
127 }, [hasLoadedMessages, isOpen, loadMessages]);
128
129 const sortedMessages = React.useMemo(
130 () => getSortedMessages(messages || [], currentTab),
131 [currentTab, messages]
132 );
133
134 function handleSubmitOpenDialog(e: React.FormEvent) {
135 e.preventDefault();
136 setDialogOpen(true);
137 }
138 async function handleSubmit(e: React.FormEvent) {
139 e.preventDefault();
140
141 if (+amountSat < 1000) {
142 toast.error("Amount too low", {
143 description: "Minimum payment is 1000 sats",
144 });
145 return;
146 }
147
148 const amountMsat = +amountSat * 1000;
149 setSubmitting(true);
150 try {
151 const transaction = await getNWCClient().makeInvoice({
152 amount: amountMsat,
153 description: messageText,
154 metadata: {
155 payer_data: {
156 name: senderName,
157 },
158 },
159 });
160
161 const payInvoiceResponse = await request<PayInvoiceResponse>(
162 `/api/payments/${transaction.invoice}`,
163 {
164 method: "POST",
165 }
166 );
167 if (!payInvoiceResponse?.preimage) {
168 throw new Error("No preimage in response");
169 }
170
171 setMessageText("");
172 loadMessages();
173 toast("Successfully sent message");
174 setDialogOpen(false);
175 } catch (error) {
176 console.error(error);
177 toast.error("Something went wrong", {
178 description: "" + error,
179 });
180 }
181 setSubmitting(false);
182 }
183
184 const topPlaceSat = Math.max(
185 1000,
186 ...(messages?.map((message) => message.amountSat + 1) || [])
187 );
188
189 return (
190 <>
191 <Card>
192 <CardHeader>
193 <div className="flex justify-between items-center">
194 <CardTitle className="flex items-center gap-2">
195 Lightning Messageboard{isLoading && <Loading />}
196 </CardTitle>
197 <Button variant="secondary" onClick={() => setOpen(!isOpen)}>
198 {isOpen ? "Hide" : "Show"}
199 </Button>
200 </div>
201 </CardHeader>
202 {isOpen && (
203 <CardContent>
204 <div className="flex gap-2 mb-4 -mt-4">
205 <Button
206 variant={currentTab === "latest" ? "default" : "outline"}
207 size="sm"
208 onClick={() => setCurrentTab("latest")}
209 >
210 Latest
211 </Button>
212 <Button
213 variant={currentTab === "top" ? "default" : "outline"}
214 size="sm"
215 onClick={() => setCurrentTab("top")}
216 >
217 Top
218 </Button>
219 </div>
220 <div className="h-96 overflow-y-visible flex flex-col gap-2 overflow-hidden">
221 {sortedMessages.map((message, index) => (
222 <div key={index}>
223 <CardHeader>
224 <CardTitle className="leading-6 break-anywhere">
225 {message.message}
226 </CardTitle>
227 </CardHeader>
228 <CardFooter className="flex items-center justify-between text-sm pb-2">
229 <CardTitle className="break-all font-normal text-xs">
230 <span className="text-muted-foreground">by</span>{" "}
231 {message.name || "Anonymous"}{" "}
232 <span className="text-muted-foreground">
233 {dayjs(message.created_at * 1000).fromNow()}
234 </span>
235 </CardTitle>
236 <div>
237 <Badge>
238 <ZapIcon />
239 <FormattedBitcoinAmount
240 amountMsat={message.amountSat * 1000}
241 />
242 </Badge>
243 </div>
244 </CardFooter>
245 {index !== sortedMessages.length - 1 && <Separator />}
246 </div>
247 ))}
248 </div>
249 <form
250 onSubmit={handleSubmitOpenDialog}
251 className="flex items-center gap-2 mt-4"
252 >
253 <Input
254 required
255 placeholder="Type your message..."
256 value={messageText}
257 maxLength={140}
258 onChange={(e) => setMessageText(e.target.value)}
259 />
260 <Button>
261 <ZapIcon />
262 Send
263 </Button>
264 </form>
265 </CardContent>
266 )}
267 </Card>
268 <Dialog onOpenChange={setDialogOpen} open={dialogOpen}>
269 <DialogContent className="sm:max-w-[600px]">
270 <form onSubmit={handleSubmit}>
271 <DialogHeader>
272 <DialogTitle>Post Message</DialogTitle>
273 <DialogDescription>
274 Pay to post on the Alby Hub message board. The messages with the
275 highest number of satoshis will be shown first.
276 </DialogDescription>
277 </DialogHeader>
278
279 <div className="grid gap-4 py-4">
280 <div className="grid grid-cols-4 items-center gap-4">
281 <Label htmlFor="comment" className="text-right">
282 Your Name
283 </Label>
284 <div className="col-span-3">
285 <Input
286 id="sender-name"
287 value={senderName}
288 onChange={(e) => setSenderName(e.target.value)}
289 maxLength={20}
290 autoFocus
291 />
292 </div>
293 </div>
294
295 <div className="grid grid-cols-4 items-center gap-4">
296 <Label htmlFor="amount" className="text-right">
297 Amount (sats)
298 </Label>
299 <div className="col-span-2">
300 <Input
301 id="amount"
302 required
303 value={amountSat}
304 onChange={(e) => setAmountSat(e.target.value)}
305 />
306 </div>
307 <Button
308 type="button"
309 variant="secondary"
310 onClick={() => setAmountSat("" + topPlaceSat)}
311 >
312 <ChevronUpIcon />
313 Top
314 </Button>
315 </div>
316 <div className="grid grid-cols-4 gap-4">
317 <Label htmlFor="comment" className="text-right pt-2">
318 Message
319 </Label>
320 <Textarea
321 id="comment"
322 value={messageText}
323 onChange={(e) => setMessageText(e.target.value)}
324 className="col-span-3"
325 rows={4}
326 />
327 </div>
328 </div>
329 <DialogFooter>
330 <LoadingButton
331 type="submit"
332 disabled={!!isSubmitting}
333 loading={isSubmitting}
334 >
335 Confirm Payment
336 </LoadingButton>
337 </DialogFooter>
338 </form>
339 </DialogContent>
340 </Dialog>
341 </>
342 );
343 }
344