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