fix: one clipboard hook that only claims success when the copy landed

This commit is contained in:
Ashen
2026-08-16 02:59:32 +05:30
parent 9c0ef7ed35
commit 3a9881cbee
12 changed files with 154 additions and 93 deletions
+4 -8
View File
@@ -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<string | null>(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');
+6 -6
View File
@@ -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<string>('all');
const [status, setStatus] = useState<string>('all');
const [copiedId, setCopiedId] = useState<string | null>(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) => {
+3 -4
View File
@@ -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<FormData>({
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 (
<FormItem>
+17 -11
View File
@@ -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<string | null>(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({
<Button
variant='outline'
size='icon'
onClick={() => navigator.clipboard.writeText(activeApiKey)}
aria-label='Copy parent API key'
onClick={() => copyToClipboard(activeApiKey)}
disabled={!activeApiKey}
>
<Copy className='h-4 w-4' />
{copiedKey === activeApiKey ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</div>
{walletInfo && (
@@ -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<void> => {
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<void> => {
if (!value) {
return;
}
if (await copy(value)) {
toast.success('Copied to clipboard');
}
},
[copy]
);
const handleCreateKey = useCallback(async (): Promise<void> => {
if (!initialToken.trim()) {
+13 -16
View File
@@ -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<void> => {
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<void> => {
if (!value) {
return;
}
if (await copy(value)) {
toast.success('Copied to clipboard');
}
},
[copy]
);
const handleApiKeyCreated = useCallback(
(apiKey: string, snapshot: WalletSnapshot) => {
+6 -3
View File
@@ -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<string | null>(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) => {
@@ -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<string>('');
const [topupApiKeyResult, setTopupApiKeyResult] = useState<string>('');
const [recoveredApiKey, setRecoveredApiKey] = useState<string>('');
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<void> => {
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<void> => {
if (!value) return;
if (await copy(value)) {
toast.success('Copied to clipboard');
}
},
[copy]
);
const pollInvoiceStatus = useCallback(
async (invoiceId: string, onPaid: (status: InvoiceStatus) => void) => {
+6 -5
View File
@@ -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({
<Button
size='sm'
variant='outline'
onClick={() => {
navigator.clipboard.writeText(
invoiceData.payment_request
);
toast.success('Invoice copied to clipboard!');
onClick={async () => {
if (await copy(invoiceData.payment_request)) {
toast.success('Invoice copied to clipboard!');
}
}}
className='w-full sm:w-auto'
>
@@ -2,6 +2,7 @@
import { useState } from 'react';
import Image from 'next/image';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
import { toast } from 'sonner';
import QRCode from 'qrcode';
@@ -35,6 +36,7 @@ export function RoutstrCreateKeySection({
onApiKeyCreated,
}: RoutstrCreateKeySectionProps) {
// Lightning state
const { copy } = useCopyToClipboard();
const [lnAmount, setLnAmount] = useState('');
const [lnInvoice, setLnInvoice] = useState<{
bolt11: string;
@@ -61,11 +63,8 @@ export function RoutstrCreateKeySection({
const cleanUrl = baseUrl.replace(/\/+$/, '');
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
if (await copy(text)) {
toast.success('Copied to clipboard');
} catch {
toast.error('Failed to copy');
}
};
@@ -22,6 +22,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, Copy, Trash2, Check } from 'lucide-react';
import { toast } from 'sonner';
import { getApiErrorMessage } from '@/lib/api/errors';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
function formatTs(ts: number | null): string {
if (!ts) return '—';
@@ -36,7 +37,7 @@ export function CliTokensSettings(): React.ReactElement {
const [expiresInDays, setExpiresInDays] = useState<string>('');
const [creating, setCreating] = useState(false);
const [newToken, setNewToken] = useState<CliTokenCreated | null>(null);
const [copied, setCopied] = useState(false);
const { copied, copy } = useCopyToClipboard();
const loadTokens = useCallback(async (): Promise<void> => {
setLoading(true);
@@ -101,9 +102,7 @@ export function CliTokensSettings(): React.ReactElement {
async function handleCopy(): Promise<void> {
if (!newToken) return;
await navigator.clipboard.writeText(newToken.token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
await copy(newToken.token);
}
return (
+69
View File
@@ -0,0 +1,69 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
async function writeToClipboard(text: string): Promise<boolean> {
// navigator.clipboard is undefined on plain-HTTP deployments (non-secure context)
if (typeof navigator !== 'undefined' && navigator.clipboard) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {}
}
// execCommand reports that a copy ran, not that anything landed, so write the
// text from the copy event and treat that firing as the proof of delivery.
let delivered = false;
const onCopy = (event: ClipboardEvent) => {
event.clipboardData?.setData('text/plain', text);
event.preventDefault();
delivered = true;
};
try {
document.addEventListener('copy', onCopy);
return document.execCommand('copy') && delivered;
} catch {
return false;
} finally {
document.removeEventListener('copy', onCopy);
}
}
/**
* Clipboard copy with a success indicator that never lies: `copied` only
* turns true when the text actually reached the clipboard.
* `key` distinguishes multiple copy targets sharing one hook instance.
*/
export function useCopyToClipboard(resetMs = 2000) {
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const latest = useRef(0);
const copy = useCallback(
async (text: string, key = 'default'): Promise<boolean> => {
// Copies can overlap; only the newest attempt owns the indicator, so a
// slow earlier failure cannot clear a later success.
const attempt = ++latest.current;
const ok = await writeToClipboard(text);
if (attempt !== latest.current) return ok;
if (timer.current) clearTimeout(timer.current);
if (ok) {
setCopiedKey(key);
timer.current = setTimeout(() => setCopiedKey(null), resetMs);
} else {
setCopiedKey(null);
toast.error('Copy failed. Your browser blocked clipboard access.');
}
return ok;
},
[resetMs]
);
useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
return { copied: copiedKey !== null, copiedKey, copy };
}