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 && <> {from}</>}
191 {to !== undefined && <> {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 (
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