From 3a9881cbee9ae0a058fe6b707f36802bf101e9f4 Mon Sep 17 00:00:00 2001 From: Ashen <310210685+ashen0x@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:27:24 +0530 Subject: [PATCH] fix: one clipboard hook that only claims success when the copy landed --- ui/app/logs/log-details-dialog.tsx | 12 ++-- ui/app/transactions/page.tsx | 12 ++-- ui/components/add-provider-model-dialog.tsx | 7 +- ui/components/child-key-creator.tsx | 28 +++++--- .../landing/cashu-payment-workflow.tsx | 29 ++++---- ui/components/landing/cheat-sheet.tsx | 29 ++++---- ui/components/landing/key-info-details.tsx | 9 ++- .../landing/lightning-payment-workflow.tsx | 27 +++----- ui/components/provider-balance.tsx | 11 +-- .../providers/RoutstrCreateKeySection.tsx | 7 +- .../settings/cli-tokens-settings.tsx | 7 +- ui/hooks/use-copy-to-clipboard.ts | 69 +++++++++++++++++++ 12 files changed, 154 insertions(+), 93 deletions(-) create mode 100644 ui/hooks/use-copy-to-clipboard.ts diff --git a/ui/app/logs/log-details-dialog.tsx b/ui/app/logs/log-details-dialog.tsx index 4192c5b1..f43a62e8 100644 --- a/ui/app/logs/log-details-dialog.tsx +++ b/ui/app/logs/log-details-dialog.tsx @@ -9,7 +9,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Copy, Check } from 'lucide-react'; -import { useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { getLogLevelBadgeVariant } from '@/lib/utils/log-level'; import type { LogEntry } from './types'; @@ -24,16 +24,12 @@ export function LogDetailsDialog({ isOpen, onClose, }: LogDetailsDialogProps) { - const [copiedField, setCopiedField] = useState(null); + const { copiedKey: copiedField, copy } = useCopyToClipboard(); if (!log) return null; - const copyToClipboard = (text: string, fieldName?: string) => { - navigator.clipboard.writeText(text); - if (fieldName) { - setCopiedField(fieldName); - setTimeout(() => setCopiedField(null), 2000); - } + const copyToClipboard = (text: string, fieldName: string) => { + void copy(text, fieldName); }; const allFields = Object.keys(log).filter((key) => key !== 'key'); diff --git a/ui/app/transactions/page.tsx b/ui/app/transactions/page.tsx index e1812bfb..0ea721c0 100644 --- a/ui/app/transactions/page.tsx +++ b/ui/app/transactions/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { useQuery, keepPreviousData } from '@tanstack/react-query'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { AppPageShell } from '@/components/app-page-shell'; import { PageHeader } from '@/components/page-header'; import { @@ -374,7 +375,7 @@ export default function TransactionsPage() { const [search, setSearch] = useState(''); const [type, setType] = useState('all'); const [status, setStatus] = useState('all'); - const [copiedId, setCopiedId] = useState(null); + const { copiedKey: copiedId, copy } = useCopyToClipboard(); // Load filters from localStorage on mount useEffect(() => { @@ -482,11 +483,10 @@ export default function TransactionsPage() { setLightningPage(0); }; - const copyToClipboard = (text: string, id: string) => { - navigator.clipboard.writeText(text); - setCopiedId(id); - toast.success('Copied to clipboard'); - setTimeout(() => setCopiedId(null), 2000); + const copyToClipboard = async (text: string, id: string) => { + if (await copy(text, id)) { + toast.success('Copied to clipboard'); + } }; const getStatusBadge = (tx: Transaction) => { diff --git a/ui/components/add-provider-model-dialog.tsx b/ui/components/add-provider-model-dialog.tsx index bd4d6b64..842a389f 100644 --- a/ui/components/add-provider-model-dialog.tsx +++ b/ui/components/add-provider-model-dialog.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { useQuery } from '@tanstack/react-query'; @@ -107,7 +108,7 @@ export function AddProviderModelDialog({ const [isPresetOpen, setIsPresetOpen] = useState(false); const [selectedPresetLabel, setSelectedPresetLabel] = useState('Select a preset'); - const [forwardedModelIdCopied, setForwardedModelIdCopied] = useState(false); + const { copied: forwardedModelIdCopied, copy } = useCopyToClipboard(1500); const form = useForm({ resolver: zodResolver(FormSchema) as never, @@ -546,9 +547,7 @@ export function AddProviderModelDialog({ const handleCopy = () => { const value = field.value || form.getValues('id'); if (!value) return; - navigator.clipboard.writeText(value); - setForwardedModelIdCopied(true); - setTimeout(() => setForwardedModelIdCopied(false), 1500); + void copy(value); }; return ( diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index 2f462179..b10847f9 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useWalletInfo } from '@/hooks/use-wallet-info'; import { WalletService } from '@/lib/api/services/wallet'; import { ApiKeyInput } from './api-key-input'; @@ -75,7 +76,7 @@ export function ChildKeyCreator({ cost_msats: number; parent_balance: number; } | null>(null); - const [copiedKey, setCopiedKey] = useState(null); + const { copiedKey, copy } = useCopyToClipboard(); const addConfig = () => { setConfigs([ @@ -163,16 +164,16 @@ export function ChildKeyCreator({ } }; - const copyToClipboard = (key: string) => { - navigator.clipboard.writeText(key); - setCopiedKey(key); - toast.success('API key copied to clipboard'); - setTimeout(() => setCopiedKey(null), 2000); + const copyToClipboard = async (key: string) => { + if (await copy(key, key)) { + toast.success('API key copied to clipboard'); + } }; - const copyAllToClipboard = () => { - navigator.clipboard.writeText(newKeys.join('\n')); - toast.success('All API keys copied to clipboard'); + const copyAllToClipboard = async () => { + if (await copy(newKeys.join('\n'), 'all')) { + toast.success('All API keys copied to clipboard'); + } }; return ( @@ -215,10 +216,15 @@ export function ChildKeyCreator({ {walletInfo && ( diff --git a/ui/components/landing/cashu-payment-workflow.tsx b/ui/components/landing/cashu-payment-workflow.tsx index 0835269c..22ece30b 100644 --- a/ui/components/landing/cashu-payment-workflow.tsx +++ b/ui/components/landing/cashu-payment-workflow.tsx @@ -1,6 +1,7 @@ 'use client'; import { type JSX, useCallback, useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { Copy, RefreshCcw } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { toast } from 'sonner'; @@ -45,6 +46,7 @@ export function CashuPaymentWorkflow({ onApiKeyCreated, onWalletInfoUpdated, }: CashuPaymentWorkflowProps): JSX.Element { + const { copy } = useCopyToClipboard(); const [initialToken, setInitialToken] = useState(''); const [topupToken, setTopupToken] = useState(''); const [apiKeyInput, setApiKeyInput] = useState(apiKey); @@ -65,22 +67,17 @@ export function CashuPaymentWorkflow({ } = useWalletInfo(baseUrl, activeApiKey); const walletInfo = propWalletInfo ?? queryWalletInfo ?? null; - const handleCopy = useCallback(async (value: string): Promise => { - if (!value) { - return; - } - if (typeof navigator === 'undefined' || !navigator.clipboard) { - toast.error('Clipboard API unavailable'); - return; - } - try { - await navigator.clipboard.writeText(value); - toast.success('Copied to clipboard'); - } catch (error) { - console.error(error); - toast.error('Unable to copy'); - } - }, []); + const handleCopy = useCallback( + async (value: string): Promise => { + if (!value) { + return; + } + if (await copy(value)) { + toast.success('Copied to clipboard'); + } + }, + [copy] + ); const handleCreateKey = useCallback(async (): Promise => { if (!initialToken.trim()) { diff --git a/ui/components/landing/cheat-sheet.tsx b/ui/components/landing/cheat-sheet.tsx index f5fd803c..dd1d0671 100644 --- a/ui/components/landing/cheat-sheet.tsx +++ b/ui/components/landing/cheat-sheet.tsx @@ -2,6 +2,7 @@ import { type JSX, useCallback, useEffect, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { Bolt, Copy, RefreshCcw, Terminal } from 'lucide-react'; import { toast } from 'sonner'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -65,6 +66,7 @@ function normalizeBaseUrl(url: string): string { } export function CheatSheet(): JSX.Element { + const { copy } = useCopyToClipboard(); const [baseUrl, setBaseUrl] = useState(() => typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl() ); @@ -98,22 +100,17 @@ export function CheatSheet(): JSX.Element { staleTime: 120_000, }); - const handleCopy = useCallback(async (value: string): Promise => { - if (!value) { - return; - } - if (typeof navigator === 'undefined' || !navigator.clipboard) { - toast.error('Clipboard API unavailable'); - return; - } - try { - await navigator.clipboard.writeText(value); - toast.success('Copied to clipboard'); - } catch (error) { - console.error(error); - toast.error('Unable to copy'); - } - }, []); + const handleCopy = useCallback( + async (value: string): Promise => { + if (!value) { + return; + } + if (await copy(value)) { + toast.success('Copied to clipboard'); + } + }, + [copy] + ); const handleApiKeyCreated = useCallback( (apiKey: string, snapshot: WalletSnapshot) => { diff --git a/ui/components/landing/key-info-details.tsx b/ui/components/landing/key-info-details.tsx index 55935d81..4fcae271 100644 --- a/ui/components/landing/key-info-details.tsx +++ b/ui/components/landing/key-info-details.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useWalletInfo } from '@/hooks/use-wallet-info'; import type { RefundReceipt } from './cashu-payment-workflow'; import { toast } from 'sonner'; @@ -58,6 +59,7 @@ export function KeyInfoDetails({ onWalletInfoUpdated, onRefundComplete, }: KeyInfoDetailsProps): React.ReactNode { + const { copy } = useCopyToClipboard(); const [apiKeyInput, setApiKeyInput] = useState(apiKey); const [isResetting, setIsResetting] = useState(null); const [isRefunding, setIsRefunding] = useState(false); @@ -92,9 +94,10 @@ export function KeyInfoDetails({ } }; - const handleCopy = (value: string) => { - navigator.clipboard.writeText(value); - toast.success('Copied to clipboard'); + const handleCopy = async (value: string) => { + if (await copy(value)) { + toast.success('Copied to clipboard'); + } }; const handleResetSpent = async (childKey: string) => { diff --git a/ui/components/landing/lightning-payment-workflow.tsx b/ui/components/landing/lightning-payment-workflow.tsx index 9cc6152c..e2dcfa9a 100644 --- a/ui/components/landing/lightning-payment-workflow.tsx +++ b/ui/components/landing/lightning-payment-workflow.tsx @@ -1,6 +1,7 @@ 'use client'; import { type JSX, useCallback, useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import Image from 'next/image'; import { Copy, CheckCircle, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; @@ -204,6 +205,7 @@ export function LightningPaymentWorkflow({ const [createdApiKey, setCreatedApiKey] = useState(''); const [topupApiKeyResult, setTopupApiKeyResult] = useState(''); const [recoveredApiKey, setRecoveredApiKey] = useState(''); + const { copy } = useCopyToClipboard(); const [isWaitingPayment, setIsWaitingPayment] = useState(false); const [isWaitingTopupPayment, setIsWaitingTopupPayment] = useState(false); @@ -218,22 +220,15 @@ export function LightningPaymentWorkflow({ const [hasInteractedTopup, setHasInteractedTopup] = useState(false); const [hasInteractedRecover, setHasInteractedRecover] = useState(false); - const handleCopy = useCallback(async (value: string): Promise => { - if (!value) return; - - if (typeof navigator === 'undefined' || !navigator.clipboard) { - toast.error('Clipboard API unavailable'); - return; - } - - try { - await navigator.clipboard.writeText(value); - toast.success('Copied to clipboard'); - } catch (error) { - console.error(error); - toast.error('Unable to copy'); - } - }, []); + const handleCopy = useCallback( + async (value: string): Promise => { + if (!value) return; + if (await copy(value)) { + toast.success('Copied to clipboard'); + } + }, + [copy] + ); const pollInvoiceStatus = useCallback( async (invoiceId: string, onPaid: (status: InvoiceStatus) => void) => { diff --git a/ui/components/provider-balance.tsx b/ui/components/provider-balance.tsx index 6ba57659..398c640b 100644 --- a/ui/components/provider-balance.tsx +++ b/ui/components/provider-balance.tsx @@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { CheckCircle2 } from 'lucide-react'; import { toast } from 'sonner'; import { AdminService } from '@/lib/api/services/admin'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; @@ -33,6 +34,7 @@ export function ProviderBalance({ isRoutstr = false, nodeUrl, }: ProviderBalanceProps) { + const { copy } = useCopyToClipboard(); const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false); const [topupAmount, setTopupAmount] = useState(''); const [topupError, setTopupError] = useState(''); @@ -257,11 +259,10 @@ export function ProviderBalance({