TransactionItem.tsx raw

   1  import dayjs from "dayjs";
   2  import relativeTime from "dayjs/plugin/relativeTime";
   3  import utc from "dayjs/plugin/utc";
   4  import {
   5    ArrowDownIcon,
   6    ArrowDownUpIcon,
   7    ArrowUpDownIcon,
   8    ArrowUpIcon,
   9    ChevronDownIcon,
  10    ChevronUpIcon,
  11    TagIcon,
  12    XIcon,
  13  } from "lucide-react";
  14  import { nip19 } from "nostr-tools";
  15  import React from "react";
  16  import { Link } from "react-router";
  17  import AppAvatar from "src/components/AppAvatar";
  18  import ExternalLink from "src/components/ExternalLink";
  19  import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
  20  import FormattedFiatAmount from "src/components/FormattedFiatAmount";
  21  import { PaymentFailedAlert } from "src/components/PaymentFailedAlert";
  22  import PodcastingInfo from "src/components/PodcastingInfo";
  23  import { TransactionDetailRow } from "src/components/TransactionDetailRow";
  24  import TransactionLabels from "src/components/TransactionLabels";
  25  import {
  26    Dialog,
  27    DialogContent,
  28    DialogHeader,
  29    DialogTitle,
  30    DialogTrigger,
  31  } from "src/components/ui/dialog";
  32  import { useApp } from "src/hooks/useApp";
  33  import { useSwap } from "src/hooks/useSwaps";
  34  import { cn, getAppDisplayName } from "src/lib/utils";
  35  import { Transaction } from "src/types";
  36  
  37  dayjs.extend(relativeTime);
  38  dayjs.extend(utc);
  39  
  40  type Props = {
  41    tx: Transaction;
  42    transactionListKey: string;
  43  };
  44  
  45  function safeNpubEncode(hex: string): string | undefined {
  46    try {
  47      return nip19.npubEncode(hex);
  48    } catch {
  49      return undefined;
  50    }
  51  }
  52  
  53  function safeNeventEncode(id: string): string | undefined {
  54    try {
  55      return nip19.neventEncode({
  56        id,
  57      });
  58    } catch {
  59      return undefined;
  60    }
  61  }
  62  
  63  function TransactionItem({ tx, transactionListKey }: Props) {
  64    const { data: app } = useApp(tx.appId);
  65    const swapId = tx.metadata?.swap_id;
  66    const { data: swap } = useSwap(swapId);
  67    const [showDetails, setShowDetails] = React.useState(false);
  68    const labels = tx.metadata?.user_labels ?? {};
  69    const labelEntries = Object.entries(labels);
  70    const type = tx.type;
  71    const updatedAt = dayjs(tx.updatedAt).local();
  72  
  73    const pubkey = tx.metadata?.nostr?.pubkey;
  74    const npub = pubkey ? safeNpubEncode(pubkey) : undefined;
  75  
  76    const payerName = tx.metadata?.payer_data?.name;
  77    const from =
  78      type === "incoming"
  79        ? payerName
  80          ? `from ${payerName}`
  81          : npub
  82            ? `zap from ${npub.substring(0, 12)}...`
  83            : swap
  84              ? `swap from ${swap.lockupAddress}`
  85              : undefined
  86        : undefined;
  87  
  88    const recipientIdentifier = tx.metadata?.recipient_data?.identifier;
  89    const to =
  90      type === "outgoing"
  91        ? npub
  92          ? `zap to ${npub.substring(0, 12)}...`
  93          : swap?.type === "out"
  94            ? `swap to ${swap.destinationAddress}`
  95            : recipientIdentifier
  96              ? `${tx.state === "failed" ? "payment " : ""}to ${recipientIdentifier}`
  97              : undefined
  98        : undefined;
  99  
 100    const eventId = tx.metadata?.nostr?.tags?.find((t) => t[0] === "e")?.[1];
 101    const nevent = eventId ? safeNeventEncode(eventId) : undefined;
 102  
 103    const bolt12Offer = tx.metadata?.offer;
 104  
 105    const description =
 106      tx.description || tx.metadata?.comment || bolt12Offer?.payer_note;
 107  
 108    const typeStateText =
 109      type == "incoming"
 110        ? "Received"
 111        : tx.state === "settled" // we only fetch settled incoming payments
 112          ? "Sent"
 113          : tx.state === "pending"
 114            ? "Sending"
 115            : "Failed";
 116  
 117    const Icon =
 118      tx.state === "failed"
 119        ? XIcon
 120        : tx.type === "outgoing"
 121          ? swapId
 122            ? ArrowUpDownIcon
 123            : ArrowUpIcon
 124          : swapId
 125            ? ArrowDownUpIcon
 126            : ArrowDownIcon;
 127  
 128    const typeStateIcon = (
 129      <div className="flex items-center">
 130        <div
 131          className={cn(
 132            "flex justify-center items-center rounded-full w-10 h-10 md:w-14 md:h-14 relative",
 133            tx.state === "failed"
 134              ? "bg-red-100 dark:bg-rose-950"
 135              : tx.state === "pending"
 136                ? "bg-blue-100 dark:bg-sky-950"
 137                : type === "outgoing"
 138                  ? "bg-orange-100 dark:bg-amber-950"
 139                  : "bg-green-100 dark:bg-emerald-950"
 140          )}
 141        >
 142          <Icon
 143            strokeWidth={3}
 144            className={cn(
 145              "size-6 md:w-8 md:h-8",
 146              tx.state === "failed"
 147                ? "stroke-red-500 dark:stroke-rose-500"
 148                : tx.state === "pending"
 149                  ? "stroke-blue-500 dark:stroke-sky-500"
 150                  : type === "outgoing"
 151                    ? "stroke-orange-500 dark:stroke-amber-500"
 152                    : "stroke-green-500 dark:stroke-teal-500"
 153            )}
 154          />
 155          {app && (
 156            <div
 157              className="absolute -bottom-1 -right-1"
 158              title={`${typeStateText} via ${getAppDisplayName(app.name)}`}
 159            >
 160              <AppAvatar
 161                app={app}
 162                className="border-none p-0 rounded-full w-4.5 h-4.5 md:w-6 md:h-6 shadow-xs"
 163              />
 164            </div>
 165          )}
 166        </div>
 167      </div>
 168    );
 169  
 170    return (
 171      <Dialog
 172        onOpenChange={(open) => {
 173          if (!open) {
 174            setShowDetails(false);
 175          }
 176        }}
 177      >
 178        <DialogTrigger className="p-3 mb-4 hover:bg-muted/50 data-[state=open]:bg-muted cursor-pointer rounded-md slashed-zero transaction sensitive">
 179          <div
 180            className={cn(
 181              "flex gap-3",
 182              tx.state === "pending" && "animate-pulse"
 183            )}
 184          >
 185            {typeStateIcon}
 186            <div className="overflow-hidden mr-3 max-w-full text-left flex flex-col items-start justify-center">
 187              <div className="flex items-center gap-2">
 188                <span className="md:text-xl font-semibold break-all line-clamp-1">
 189                  {typeStateText}
 190                  {from !== undefined && <>&nbsp;{from}</>}
 191                  {to !== undefined && <>&nbsp;{to}</>}
 192                </span>
 193                <span className="text-xs md:text-base text-muted-foreground shrink-0">
 194                  {updatedAt.fromNow()}
 195                </span>
 196                {labelEntries.length > 0 && (
 197                  <TagIcon
 198                    className="size-3 text-muted-foreground shrink-0"
 199                    aria-label={`${labelEntries.length} label${labelEntries.length === 1 ? "" : "s"}`}
 200                  />
 201                )}
 202              </div>
 203              <p className="text-sm md:text-base text-muted-foreground break-all line-clamp-1">
 204                {description}
 205              </p>
 206            </div>
 207            <div className="flex ml-auto space-x-3 shrink-0">
 208              <div className="flex flex-col items-end md:text-xl">
 209                <div className="flex flex-row gap-1">
 210                  <p
 211                    className={cn(
 212                      type == "incoming" && "text-green-600 dark:text-emerald-500"
 213                    )}
 214                  >
 215                    {type == "outgoing" ? "-" : "+"}
 216                    <FormattedBitcoinAmount
 217                      amountMsat={tx.amountMsat}
 218                      className="font-medium"
 219                    />
 220                  </p>
 221                </div>
 222                <FormattedFiatAmount
 223                  className="text-xs md:text-base"
 224                  amountSat={tx.amountSat}
 225                />
 226              </div>
 227            </div>
 228          </div>
 229        </DialogTrigger>
 230        <DialogContent className="slashed-zero max-h-[90vh]">
 231          <DialogHeader>
 232            <DialogTitle
 233              className={cn(tx.state === "pending" && "animate-pulse")}
 234            >{`${typeStateText} Bitcoin Payment`}</DialogTitle>
 235          </DialogHeader>
 236          <div className="space-y-6 text-sm mt-2">
 237            <div
 238              className={cn(
 239                "flex items-center",
 240                tx.state === "pending" && "animate-pulse"
 241              )}
 242            >
 243              {typeStateIcon}
 244              <div className="ml-4">
 245                <p className="text-xl md:text-2xl font-semibold sensitive">
 246                  {tx.type === "outgoing" ? "-" : "+"}
 247                  <FormattedBitcoinAmount amountMsat={tx.amountMsat} />
 248                </p>
 249                <FormattedFiatAmount amountSat={tx.amountSat} />
 250              </div>
 251            </div>
 252            {app && (
 253              <TransactionDetailRow label="App">
 254                <Link to={`/apps/${app.id}`}>
 255                  <p className="font-semibold text-foreground">
 256                    {getAppDisplayName(app.name)}
 257                  </p>
 258                </Link>
 259              </TransactionDetailRow>
 260            )}
 261            {swapId && (
 262              <TransactionDetailRow label="Swap Id">
 263                <Link
 264                  to={`/wallet/swap/${type === "incoming" ? "in" : "out"}/status/${swapId}`}
 265                  className="flex items-center gap-1"
 266                >
 267                  <p className="underline">{swapId}</p>
 268                </Link>
 269              </TransactionDetailRow>
 270            )}
 271            {to && <TransactionDetailRow label="To">{to}</TransactionDetailRow>}
 272            {payerName && (
 273              <TransactionDetailRow label="From">
 274                {payerName}
 275              </TransactionDetailRow>
 276            )}
 277            <TransactionDetailRow label="Date & Time">
 278              {updatedAt.format("D MMMM YYYY, HH:mm")}
 279            </TransactionDetailRow>
 280            {tx.state != "failed" && tx.feesPaidMsat > 0 && (
 281              <TransactionDetailRow label="Fee">
 282                <FormattedBitcoinAmount amountMsat={tx.feesPaidMsat} />
 283                {type == "outgoing" && (
 284                  <>
 285                    &nbsp;(
 286                    {((tx.feesPaidMsat / tx.amountMsat) * 100).toFixed(2)}%)
 287                  </>
 288                )}
 289              </TransactionDetailRow>
 290            )}
 291            {tx.description && (
 292              <TransactionDetailRow label="Description">
 293                {tx.description}
 294              </TransactionDetailRow>
 295            )}
 296            {tx.metadata?.comment && (
 297              <TransactionDetailRow label="Comment">
 298                {tx.metadata.comment}
 299              </TransactionDetailRow>
 300            )}
 301            {bolt12Offer?.payer_note && (
 302              <TransactionDetailRow label="Payer Note">
 303                {bolt12Offer.payer_note}
 304              </TransactionDetailRow>
 305            )}
 306            {/* for Alby lightning addresses the content of the zap request is
 307              automatically extracted and already displayed above as description */}
 308            {tx.metadata?.nostr && nevent && npub && (
 309              <TransactionDetailRow
 310                label={
 311                  <ExternalLink
 312                    to={`https://njump.me/${nevent}`}
 313                    className="underline"
 314                  >
 315                    Nostr Zap
 316                  </ExternalLink>
 317                }
 318              >
 319                from {npub}
 320              </TransactionDetailRow>
 321            )}
 322            {tx.state === "failed" && (
 323              <div>
 324                <PaymentFailedAlert
 325                  errorMessage={tx.failureReason}
 326                  invoice={tx.invoice}
 327                />
 328              </div>
 329            )}
 330            <TransactionLabels
 331              id={tx.id}
 332              labels={labels}
 333              transactionListKey={transactionListKey}
 334            />
 335            <div className="w-full">
 336              <div
 337                className="flex items-center gap-2 cursor-pointer"
 338                onClick={() => setShowDetails(!showDetails)}
 339              >
 340                Details
 341                {showDetails ? (
 342                  <ChevronUpIcon className="size-4" />
 343                ) : (
 344                  <ChevronDownIcon className="size-4" />
 345                )}
 346              </div>
 347              {showDetails && (
 348                <div className="flex flex-col gap-6 mt-6">
 349                  {tx.boostagram && <PodcastingInfo boost={tx.boostagram} />}
 350                  {bolt12Offer && (
 351                    <TransactionDetailRow
 352                      label="BOLT-12 Offer Id"
 353                      copyable={bolt12Offer.id}
 354                    >
 355                      {bolt12Offer.id}
 356                    </TransactionDetailRow>
 357                  )}
 358                  {tx.preimage && (
 359                    <TransactionDetailRow label="Preimage" copyable={tx.preimage}>
 360                      {tx.preimage}
 361                    </TransactionDetailRow>
 362                  )}
 363                  <TransactionDetailRow label="Hash" copyable={tx.paymentHash}>
 364                    {tx.paymentHash}
 365                  </TransactionDetailRow>
 366                  <TransactionDetailRow label="Invoice" copyable={tx.invoice}>
 367                    {tx.invoice}
 368                  </TransactionDetailRow>
 369                  {!!tx.failureReason && (
 370                    <TransactionDetailRow
 371                      label="Failure Reason"
 372                      copyable={tx.failureReason}
 373                    >
 374                      {tx.failureReason}
 375                    </TransactionDetailRow>
 376                  )}
 377                  {tx.metadata && (
 378                    <TransactionDetailRow
 379                      label="Metadata"
 380                      copyable={JSON.stringify(tx.metadata)}
 381                    >
 382                      {JSON.stringify(tx.metadata)}
 383                    </TransactionDetailRow>
 384                  )}
 385                </div>
 386              )}
 387            </div>
 388          </div>
 389        </DialogContent>
 390      </Dialog>
 391    );
 392  }
 393  
 394  export default TransactionItem;
 395