ChannelsCards.tsx raw

   1  import { InfoIcon } from "lucide-react";
   2  import { ChannelDropdownMenu } from "src/components/channels/ChannelDropdownMenu";
   3  import { ChannelWarning } from "src/components/channels/ChannelWarning";
   4  import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
   5  import { Badge } from "src/components/ui/badge.tsx";
   6  import {
   7    Card,
   8    CardContent,
   9    CardDescription,
  10    CardHeader,
  11    CardTitle,
  12  } from "src/components/ui/card";
  13  import { Progress } from "src/components/ui/progress.tsx";
  14  import { Separator } from "src/components/ui/separator";
  15  import {
  16    Tooltip,
  17    TooltipContent,
  18    TooltipProvider,
  19    TooltipTrigger,
  20  } from "src/components/ui/tooltip";
  21  import { useNodeDetails } from "src/hooks/useNodeDetails";
  22  import { formatAmount } from "src/lib/utils.ts";
  23  import { Channel, LongUnconfirmedZeroConfChannel } from "src/types";
  24  
  25  type ChannelsCardsProps = {
  26    channels?: Channel[];
  27    longUnconfirmedZeroConfChannels: LongUnconfirmedZeroConfChannel[];
  28  };
  29  
  30  export function ChannelsCards({
  31    channels,
  32    longUnconfirmedZeroConfChannels,
  33  }: ChannelsCardsProps) {
  34    if (!channels?.length) {
  35      return null;
  36    }
  37  
  38    return (
  39      <Card className="lg:hidden">
  40        <CardHeader className="w-full pb-2 text-2xl font-semibold">
  41          Channels
  42        </CardHeader>
  43        <CardContent>
  44          {channels
  45            .sort((a, b) =>
  46              a.localBalanceMsat + a.remoteBalanceMsat >
  47              b.localBalanceMsat + b.remoteBalanceMsat
  48                ? -1
  49                : 1
  50            )
  51            .map((channel, index) => {
  52              const unconfirmedChannel = longUnconfirmedZeroConfChannels.find(
  53                (uc) => uc.id === channel.id
  54              );
  55              return (
  56                <ChannelCard
  57                  key={index}
  58                  addSeparator={index > 0}
  59                  channel={channel}
  60                  unconfirmedChannel={unconfirmedChannel}
  61                  hasMultipleChannels={channels.length > 1}
  62                />
  63              );
  64            })}
  65        </CardContent>
  66      </Card>
  67    );
  68  }
  69  type ChannelCardProps = {
  70    channel: Channel;
  71    unconfirmedChannel: LongUnconfirmedZeroConfChannel | undefined;
  72    addSeparator: boolean;
  73    hasMultipleChannels: boolean;
  74  };
  75  
  76  function ChannelCard({
  77    channel,
  78    unconfirmedChannel,
  79    addSeparator,
  80    hasMultipleChannels,
  81  }: ChannelCardProps) {
  82    const { data: node } = useNodeDetails(channel.remotePubkey);
  83    const alias = node?.alias || "Unknown";
  84    const capacityMsat = channel.localBalanceMsat + channel.remoteBalanceMsat;
  85  
  86    return (
  87      <>
  88        {addSeparator && <Separator className="mt-6 -mb-2" />}
  89        <div className="flex flex-col items-start w-full">
  90          <CardHeader className="pb-4 px-0 w-full">
  91            <CardTitle className="w-full">
  92              <div className="flex items-center justify-between">
  93                <div className="flex-1 whitespace-nowrap text-ellipsis font-semibold truncate leading-normal">
  94                  {alias}
  95                </div>
  96                <ChannelDropdownMenu
  97                  alias={alias}
  98                  channel={channel}
  99                  hasMultipleChannels={hasMultipleChannels}
 100                />
 101              </div>
 102            </CardTitle>
 103          </CardHeader>
 104          <CardDescription className="w-full flex flex-col gap-4">
 105            <div className="flex w-full justify-between items-center">
 106              <p className="text-muted-foreground font-medium">Status</p>
 107              {channel.status == "online" ? (
 108                unconfirmedChannel ? (
 109                  <Badge variant="outline" title={unconfirmedChannel.message}>
 110                    Unconfirmed
 111                  </Badge>
 112                ) : (
 113                  <Badge variant="positive">Online</Badge>
 114                )
 115              ) : channel.status == "opening" ? (
 116                <Badge variant="outline">Opening</Badge>
 117              ) : (
 118                <Badge variant="warning">Offline</Badge>
 119              )}
 120            </div>
 121            <div className="flex w-full justify-between items-center">
 122              <TooltipProvider>
 123                <Tooltip>
 124                  <TooltipTrigger>
 125                    <div className="flex flex-row gap-1 items-center text-muted-foreground">
 126                      Type
 127                      <InfoIcon className="h-3 w-3 shrink-0" />
 128                    </div>
 129                  </TooltipTrigger>
 130                  <TooltipContent>
 131                    The type of lightning channel, By default private channel is
 132                    recommended. If you a podcaster or musician and expect to
 133                    receive keysend or Value4Value payments you will need a public
 134                    channel.
 135                  </TooltipContent>
 136                </Tooltip>
 137              </TooltipProvider>
 138              <p className="text-foreground">
 139                {channel.public ? "Public" : "Private"}
 140              </p>
 141            </div>
 142            <div className="flex justify-between items-center">
 143              <TooltipProvider>
 144                <Tooltip>
 145                  <TooltipTrigger>
 146                    <div className="flex flex-row gap-1 items-center text-muted-foreground">
 147                      Capacity
 148                      <InfoIcon className="h-3 w-3 shrink-0" />
 149                    </div>
 150                  </TooltipTrigger>
 151                  <TooltipContent>
 152                    Total Spending and Receiving capacity of your lightning
 153                    channel.
 154                  </TooltipContent>
 155                </Tooltip>
 156              </TooltipProvider>
 157  
 158              <p className="text-foreground">
 159                <FormattedBitcoinAmount amountMsat={capacityMsat} />
 160              </p>
 161            </div>
 162            <div className="flex justify-between items-center">
 163              <TooltipProvider>
 164                <Tooltip>
 165                  <TooltipTrigger>
 166                    <div className="flex flex-row gap-1 items-center text-muted-foreground">
 167                      Reserve
 168                      <InfoIcon className="h-3 w-3 shrink-0" />
 169                    </div>
 170                  </TooltipTrigger>
 171                  <TooltipContent>
 172                    Funds each participant sets aside to discourage cheating by
 173                    ensuring each party has something at stake. This reserve
 174                    cannot be spent during the channel's lifetime and typically
 175                    amounts to 1% of the channel capacity. The reserve will
 176                    automatically be filled when payments are received on the
 177                    channel.
 178                  </TooltipContent>
 179                </Tooltip>
 180              </TooltipProvider>
 181  
 182              <p className="text-foreground">
 183                {channel.localBalanceMsat <
 184                  channel.unspendablePunishmentReserveSat * 1000 && (
 185                  <>
 186                    {formatAmount(
 187                      Math.min(
 188                        channel.localBalanceMsat,
 189                        channel.unspendablePunishmentReserveSat * 1000
 190                      )
 191                    )}{" "}
 192                    /{" "}
 193                  </>
 194                )}
 195                <FormattedBitcoinAmount
 196                  amountMsat={channel.unspendablePunishmentReserveSat * 1000}
 197                />
 198              </p>
 199            </div>
 200            <div className="flex justify-between items-center">
 201              <p className="text-muted-foreground font-medium text-sm">
 202                Spending
 203              </p>
 204              <p className="text-muted-foreground font-medium text-sm">
 205                Receiving
 206              </p>
 207            </div>
 208            <div className="flex gap-2 items-center">
 209              <div className="flex-1 relative">
 210                <Progress
 211                  value={(channel.localSpendableBalanceMsat / capacityMsat) * 100}
 212                  className="h-6 absolute"
 213                />
 214                <div className="flex flex-row w-full justify-between px-2 text-xs items-center h-6 mix-blend-exclusion text-white">
 215                  <span>
 216                    <FormattedBitcoinAmount
 217                      amountMsat={channel.localSpendableBalanceMsat}
 218                    />
 219                  </span>
 220                  <span>
 221                    <FormattedBitcoinAmount
 222                      amountMsat={channel.remoteBalanceMsat}
 223                    />
 224                  </span>
 225                </div>
 226              </div>
 227              <ChannelWarning channel={channel} />
 228            </div>
 229          </CardDescription>
 230        </div>
 231      </>
 232    );
 233  }
 234