Merge pull request #656 from ashen0x/ui/copy-to-clipboard-hook

fix: one clipboard hook that only claims success when the copy landed
This commit is contained in:
9qeklajc
2026-08-16 12:20:17 +02:00
committed by GitHub
12 changed files with 154 additions and 93 deletions
+4 -8
View File
@@ -9,7 +9,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Copy, Check } from 'lucide-react'; 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 { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
import type { LogEntry } from './types'; import type { LogEntry } from './types';
@@ -24,16 +24,12 @@ export function LogDetailsDialog({
isOpen, isOpen,
onClose, onClose,
}: LogDetailsDialogProps) { }: LogDetailsDialogProps) {
const [copiedField, setCopiedField] = useState<string | null>(null); const { copiedKey: copiedField, copy } = useCopyToClipboard();
if (!log) return null; if (!log) return null;
const copyToClipboard = (text: string, fieldName?: string) => { const copyToClipboard = (text: string, fieldName: string) => {
navigator.clipboard.writeText(text); void copy(text, fieldName);
if (fieldName) {
setCopiedField(fieldName);
setTimeout(() => setCopiedField(null), 2000);
}
}; };
const allFields = Object.keys(log).filter((key) => key !== 'key'); const allFields = Object.keys(log).filter((key) => key !== 'key');
+5 -5
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { AppPageShell } from '@/components/app-page-shell'; import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { import {
@@ -374,7 +375,7 @@ export default function TransactionsPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [type, setType] = useState<string>('all'); const [type, setType] = useState<string>('all');
const [status, setStatus] = 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 // Load filters from localStorage on mount
useEffect(() => { useEffect(() => {
@@ -482,11 +483,10 @@ export default function TransactionsPage() {
setLightningPage(0); setLightningPage(0);
}; };
const copyToClipboard = (text: string, id: string) => { const copyToClipboard = async (text: string, id: string) => {
navigator.clipboard.writeText(text); if (await copy(text, id)) {
setCopiedId(id);
toast.success('Copied to clipboard'); toast.success('Copied to clipboard');
setTimeout(() => setCopiedId(null), 2000); }
}; };
const getStatusBadge = (tx: Transaction) => { const getStatusBadge = (tx: Transaction) => {
+3 -4
View File
@@ -2,6 +2,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { z } from 'zod'; import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -107,7 +108,7 @@ export function AddProviderModelDialog({
const [isPresetOpen, setIsPresetOpen] = useState(false); const [isPresetOpen, setIsPresetOpen] = useState(false);
const [selectedPresetLabel, setSelectedPresetLabel] = const [selectedPresetLabel, setSelectedPresetLabel] =
useState('Select a preset'); useState('Select a preset');
const [forwardedModelIdCopied, setForwardedModelIdCopied] = useState(false); const { copied: forwardedModelIdCopied, copy } = useCopyToClipboard(1500);
const form = useForm<FormData>({ const form = useForm<FormData>({
resolver: zodResolver(FormSchema) as never, resolver: zodResolver(FormSchema) as never,
@@ -546,9 +547,7 @@ export function AddProviderModelDialog({
const handleCopy = () => { const handleCopy = () => {
const value = field.value || form.getValues('id'); const value = field.value || form.getValues('id');
if (!value) return; if (!value) return;
navigator.clipboard.writeText(value); void copy(value);
setForwardedModelIdCopied(true);
setTimeout(() => setForwardedModelIdCopied(false), 1500);
}; };
return ( return (
<FormItem> <FormItem>
+14 -8
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { useWalletInfo } from '@/hooks/use-wallet-info'; import { useWalletInfo } from '@/hooks/use-wallet-info';
import { WalletService } from '@/lib/api/services/wallet'; import { WalletService } from '@/lib/api/services/wallet';
import { ApiKeyInput } from './api-key-input'; import { ApiKeyInput } from './api-key-input';
@@ -75,7 +76,7 @@ export function ChildKeyCreator({
cost_msats: number; cost_msats: number;
parent_balance: number; parent_balance: number;
} | null>(null); } | null>(null);
const [copiedKey, setCopiedKey] = useState<string | null>(null); const { copiedKey, copy } = useCopyToClipboard();
const addConfig = () => { const addConfig = () => {
setConfigs([ setConfigs([
@@ -163,16 +164,16 @@ export function ChildKeyCreator({
} }
}; };
const copyToClipboard = (key: string) => { const copyToClipboard = async (key: string) => {
navigator.clipboard.writeText(key); if (await copy(key, key)) {
setCopiedKey(key);
toast.success('API key copied to clipboard'); toast.success('API key copied to clipboard');
setTimeout(() => setCopiedKey(null), 2000); }
}; };
const copyAllToClipboard = () => { const copyAllToClipboard = async () => {
navigator.clipboard.writeText(newKeys.join('\n')); if (await copy(newKeys.join('\n'), 'all')) {
toast.success('All API keys copied to clipboard'); toast.success('All API keys copied to clipboard');
}
}; };
return ( return (
@@ -215,10 +216,15 @@ export function ChildKeyCreator({
<Button <Button
variant='outline' variant='outline'
size='icon' size='icon'
onClick={() => navigator.clipboard.writeText(activeApiKey)} aria-label='Copy parent API key'
onClick={() => copyToClipboard(activeApiKey)}
disabled={!activeApiKey} disabled={!activeApiKey}
> >
{copiedKey === activeApiKey ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' /> <Copy className='h-4 w-4' />
)}
</Button> </Button>
</div> </div>
{walletInfo && ( {walletInfo && (
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { type JSX, useCallback, useState } from 'react'; import { type JSX, useCallback, useState } from 'react';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { Copy, RefreshCcw } from 'lucide-react'; import { Copy, RefreshCcw } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -45,6 +46,7 @@ export function CashuPaymentWorkflow({
onApiKeyCreated, onApiKeyCreated,
onWalletInfoUpdated, onWalletInfoUpdated,
}: CashuPaymentWorkflowProps): JSX.Element { }: CashuPaymentWorkflowProps): JSX.Element {
const { copy } = useCopyToClipboard();
const [initialToken, setInitialToken] = useState(''); const [initialToken, setInitialToken] = useState('');
const [topupToken, setTopupToken] = useState(''); const [topupToken, setTopupToken] = useState('');
const [apiKeyInput, setApiKeyInput] = useState(apiKey); const [apiKeyInput, setApiKeyInput] = useState(apiKey);
@@ -65,22 +67,17 @@ export function CashuPaymentWorkflow({
} = useWalletInfo(baseUrl, activeApiKey); } = useWalletInfo(baseUrl, activeApiKey);
const walletInfo = propWalletInfo ?? queryWalletInfo ?? null; const walletInfo = propWalletInfo ?? queryWalletInfo ?? null;
const handleCopy = useCallback(async (value: string): Promise<void> => { const handleCopy = useCallback(
async (value: string): Promise<void> => {
if (!value) { if (!value) {
return; return;
} }
if (typeof navigator === 'undefined' || !navigator.clipboard) { if (await copy(value)) {
toast.error('Clipboard API unavailable');
return;
}
try {
await navigator.clipboard.writeText(value);
toast.success('Copied to clipboard'); toast.success('Copied to clipboard');
} catch (error) {
console.error(error);
toast.error('Unable to copy');
} }
}, []); },
[copy]
);
const handleCreateKey = useCallback(async (): Promise<void> => { const handleCreateKey = useCallback(async (): Promise<void> => {
if (!initialToken.trim()) { if (!initialToken.trim()) {
+8 -11
View File
@@ -2,6 +2,7 @@
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react'; import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { Bolt, Copy, RefreshCcw, Terminal } from 'lucide-react'; import { Bolt, Copy, RefreshCcw, Terminal } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -65,6 +66,7 @@ function normalizeBaseUrl(url: string): string {
} }
export function CheatSheet(): JSX.Element { export function CheatSheet(): JSX.Element {
const { copy } = useCopyToClipboard();
const [baseUrl, setBaseUrl] = useState(() => const [baseUrl, setBaseUrl] = useState(() =>
typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl() typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl()
); );
@@ -98,22 +100,17 @@ export function CheatSheet(): JSX.Element {
staleTime: 120_000, staleTime: 120_000,
}); });
const handleCopy = useCallback(async (value: string): Promise<void> => { const handleCopy = useCallback(
async (value: string): Promise<void> => {
if (!value) { if (!value) {
return; return;
} }
if (typeof navigator === 'undefined' || !navigator.clipboard) { if (await copy(value)) {
toast.error('Clipboard API unavailable');
return;
}
try {
await navigator.clipboard.writeText(value);
toast.success('Copied to clipboard'); toast.success('Copied to clipboard');
} catch (error) {
console.error(error);
toast.error('Unable to copy');
} }
}, []); },
[copy]
);
const handleApiKeyCreated = useCallback( const handleApiKeyCreated = useCallback(
(apiKey: string, snapshot: WalletSnapshot) => { (apiKey: string, snapshot: WalletSnapshot) => {
+5 -2
View File
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { useWalletInfo } from '@/hooks/use-wallet-info'; import { useWalletInfo } from '@/hooks/use-wallet-info';
import type { RefundReceipt } from './cashu-payment-workflow'; import type { RefundReceipt } from './cashu-payment-workflow';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -58,6 +59,7 @@ export function KeyInfoDetails({
onWalletInfoUpdated, onWalletInfoUpdated,
onRefundComplete, onRefundComplete,
}: KeyInfoDetailsProps): React.ReactNode { }: KeyInfoDetailsProps): React.ReactNode {
const { copy } = useCopyToClipboard();
const [apiKeyInput, setApiKeyInput] = useState(apiKey); const [apiKeyInput, setApiKeyInput] = useState(apiKey);
const [isResetting, setIsResetting] = useState<string | null>(null); const [isResetting, setIsResetting] = useState<string | null>(null);
const [isRefunding, setIsRefunding] = useState(false); const [isRefunding, setIsRefunding] = useState(false);
@@ -92,9 +94,10 @@ export function KeyInfoDetails({
} }
}; };
const handleCopy = (value: string) => { const handleCopy = async (value: string) => {
navigator.clipboard.writeText(value); if (await copy(value)) {
toast.success('Copied to clipboard'); toast.success('Copied to clipboard');
}
}; };
const handleResetSpent = async (childKey: string) => { const handleResetSpent = async (childKey: string) => {
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { type JSX, useCallback, useState } from 'react'; import { type JSX, useCallback, useState } from 'react';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import Image from 'next/image'; import Image from 'next/image';
import { Copy, CheckCircle, Loader2 } from 'lucide-react'; import { Copy, CheckCircle, Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -204,6 +205,7 @@ export function LightningPaymentWorkflow({
const [createdApiKey, setCreatedApiKey] = useState<string>(''); const [createdApiKey, setCreatedApiKey] = useState<string>('');
const [topupApiKeyResult, setTopupApiKeyResult] = useState<string>(''); const [topupApiKeyResult, setTopupApiKeyResult] = useState<string>('');
const [recoveredApiKey, setRecoveredApiKey] = useState<string>(''); const [recoveredApiKey, setRecoveredApiKey] = useState<string>('');
const { copy } = useCopyToClipboard();
const [isWaitingPayment, setIsWaitingPayment] = useState(false); const [isWaitingPayment, setIsWaitingPayment] = useState(false);
const [isWaitingTopupPayment, setIsWaitingTopupPayment] = useState(false); const [isWaitingTopupPayment, setIsWaitingTopupPayment] = useState(false);
@@ -218,22 +220,15 @@ export function LightningPaymentWorkflow({
const [hasInteractedTopup, setHasInteractedTopup] = useState(false); const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
const [hasInteractedRecover, setHasInteractedRecover] = useState(false); const [hasInteractedRecover, setHasInteractedRecover] = useState(false);
const handleCopy = useCallback(async (value: string): Promise<void> => { const handleCopy = useCallback(
async (value: string): Promise<void> => {
if (!value) return; if (!value) return;
if (await copy(value)) {
if (typeof navigator === 'undefined' || !navigator.clipboard) {
toast.error('Clipboard API unavailable');
return;
}
try {
await navigator.clipboard.writeText(value);
toast.success('Copied to clipboard'); toast.success('Copied to clipboard');
} catch (error) {
console.error(error);
toast.error('Unable to copy');
} }
}, []); },
[copy]
);
const pollInvoiceStatus = useCallback( const pollInvoiceStatus = useCallback(
async (invoiceId: string, onPaid: (status: InvoiceStatus) => void) => { async (invoiceId: string, onPaid: (status: InvoiceStatus) => void) => {
+5 -4
View File
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2 } from 'lucide-react'; import { CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { AdminService } from '@/lib/api/services/admin'; import { AdminService } from '@/lib/api/services/admin';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -33,6 +34,7 @@ export function ProviderBalance({
isRoutstr = false, isRoutstr = false,
nodeUrl, nodeUrl,
}: ProviderBalanceProps) { }: ProviderBalanceProps) {
const { copy } = useCopyToClipboard();
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false); const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
const [topupAmount, setTopupAmount] = useState(''); const [topupAmount, setTopupAmount] = useState('');
const [topupError, setTopupError] = useState(''); const [topupError, setTopupError] = useState('');
@@ -257,11 +259,10 @@ export function ProviderBalance({
<Button <Button
size='sm' size='sm'
variant='outline' variant='outline'
onClick={() => { onClick={async () => {
navigator.clipboard.writeText( if (await copy(invoiceData.payment_request)) {
invoiceData.payment_request
);
toast.success('Invoice copied to clipboard!'); toast.success('Invoice copied to clipboard!');
}
}} }}
className='w-full sm:w-auto' className='w-full sm:w-auto'
> >
@@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react'; import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
@@ -35,6 +36,7 @@ export function RoutstrCreateKeySection({
onApiKeyCreated, onApiKeyCreated,
}: RoutstrCreateKeySectionProps) { }: RoutstrCreateKeySectionProps) {
// Lightning state // Lightning state
const { copy } = useCopyToClipboard();
const [lnAmount, setLnAmount] = useState(''); const [lnAmount, setLnAmount] = useState('');
const [lnInvoice, setLnInvoice] = useState<{ const [lnInvoice, setLnInvoice] = useState<{
bolt11: string; bolt11: string;
@@ -61,11 +63,8 @@ export function RoutstrCreateKeySection({
const cleanUrl = baseUrl.replace(/\/+$/, ''); const cleanUrl = baseUrl.replace(/\/+$/, '');
const handleCopy = async (text: string) => { const handleCopy = async (text: string) => {
try { if (await copy(text)) {
await navigator.clipboard.writeText(text);
toast.success('Copied to clipboard'); 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 { AlertCircle, Copy, Trash2, Check } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getApiErrorMessage } from '@/lib/api/errors'; import { getApiErrorMessage } from '@/lib/api/errors';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
function formatTs(ts: number | null): string { function formatTs(ts: number | null): string {
if (!ts) return '—'; if (!ts) return '—';
@@ -36,7 +37,7 @@ export function CliTokensSettings(): React.ReactElement {
const [expiresInDays, setExpiresInDays] = useState<string>(''); const [expiresInDays, setExpiresInDays] = useState<string>('');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [newToken, setNewToken] = useState<CliTokenCreated | null>(null); const [newToken, setNewToken] = useState<CliTokenCreated | null>(null);
const [copied, setCopied] = useState(false); const { copied, copy } = useCopyToClipboard();
const loadTokens = useCallback(async (): Promise<void> => { const loadTokens = useCallback(async (): Promise<void> => {
setLoading(true); setLoading(true);
@@ -101,9 +102,7 @@ export function CliTokensSettings(): React.ReactElement {
async function handleCopy(): Promise<void> { async function handleCopy(): Promise<void> {
if (!newToken) return; if (!newToken) return;
await navigator.clipboard.writeText(newToken.token); await copy(newToken.token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} }
return ( 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 };
}