diff --git a/src/components/onboarding/seed-security-step.tsx b/src/components/onboarding/seed-security-step.tsx index 840ee26..5b2e8eb 100644 --- a/src/components/onboarding/seed-security-step.tsx +++ b/src/components/onboarding/seed-security-step.tsx @@ -5,6 +5,7 @@ import { Checkbox } from '@/components/ui/checkbox' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { useClipboardCopy } from '@/hooks/use-clipboard-copy' +import { SEED_CLIPBOARD_CLEAR_MS } from '@/lib/clipboard' type SeedSecurityStepProps = { variant: 'onboarding' | 'add-address' @@ -29,6 +30,7 @@ const SeedSecurityStep = ({ const handleCopy = () => { copyText(seed, { messages: { successTitle: t('onboarding.seedSecurity.seedCopied') }, + clearAfterMs: SEED_CLIPBOARD_CLEAR_MS, }) } diff --git a/src/components/pages/manage-accounts/reveal-seed-drawer.tsx b/src/components/pages/manage-accounts/reveal-seed-drawer.tsx index aaef1d5..50a86b9 100644 --- a/src/components/pages/manage-accounts/reveal-seed-drawer.tsx +++ b/src/components/pages/manage-accounts/reveal-seed-drawer.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/drawer' import { Textarea } from '@/components/ui/textarea' import { useClipboardCopy } from '@/hooks/use-clipboard-copy' +import { SEED_CLIPBOARD_CLEAR_MS } from '@/lib/clipboard' type RevealSeedDrawerProps = { open: boolean @@ -47,6 +48,7 @@ const RevealSeedDrawer = ({ open, seed, accountName, onOpenChange }: RevealSeedD const handleCopy = () => { copyText(seed, { messages: { successTitle: t('accounts.manage.seedCopied') }, + clearAfterMs: SEED_CLIPBOARD_CLEAR_MS, }) } diff --git a/src/hooks/use-clipboard-copy.ts b/src/hooks/use-clipboard-copy.ts index 1127794..89c56d8 100644 --- a/src/hooks/use-clipboard-copy.ts +++ b/src/hooks/use-clipboard-copy.ts @@ -1,7 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' +import { + cancelPendingClipboardClear, + scheduleClipboardClear, + writeToClipboard, +} from '@/lib/clipboard' const COPY_TOAST_DURATION_MS = 1000 +const COPIED_KEY_INDICATOR_MS = 1200 type CopyMessages = { successTitle?: string @@ -13,16 +19,17 @@ type CopyMessages = { type CopyOptions = { key?: string messages?: CopyMessages + clearAfterMs?: number } export const useClipboardCopy = (defaults?: CopyMessages) => { const [copiedKey, setCopiedKey] = useState(null) - const timerRef = useRef(null) + const indicatorTimerRef = useRef(null) useEffect( () => () => { - if (timerRef.current) { - window.clearTimeout(timerRef.current) + if (indicatorTimerRef.current) { + window.clearTimeout(indicatorTimerRef.current) } }, [], @@ -32,25 +39,8 @@ export const useClipboardCopy = (defaults?: CopyMessages) => { async (value: string, options?: CopyOptions) => { const messages = { ...defaults, ...options?.messages } - try { - await navigator.clipboard.writeText(value) - if (messages.successTitle) { - toast.success(messages.successTitle, { - description: messages.successDescription, - duration: COPY_TOAST_DURATION_MS, - }) - } - if (options?.key) { - setCopiedKey(options.key) - if (timerRef.current) { - window.clearTimeout(timerRef.current) - } - timerRef.current = window.setTimeout(() => { - setCopiedKey((current) => (current === options.key ? null : current)) - }, 1200) - } - return true - } catch { + const success = await writeToClipboard(value) + if (!success) { if (messages.errorTitle) { toast.error(messages.errorTitle, { description: messages.errorDescription, @@ -58,6 +48,30 @@ export const useClipboardCopy = (defaults?: CopyMessages) => { } return false } + + if (messages.successTitle) { + toast.success(messages.successTitle, { + description: messages.successDescription, + duration: COPY_TOAST_DURATION_MS, + }) + } + + if (options?.key) { + setCopiedKey(options.key) + if (indicatorTimerRef.current) { + window.clearTimeout(indicatorTimerRef.current) + } + indicatorTimerRef.current = window.setTimeout(() => { + setCopiedKey((current) => (current === options.key ? null : current)) + }, COPIED_KEY_INDICATOR_MS) + } + + cancelPendingClipboardClear() + if (options?.clearAfterMs) { + scheduleClipboardClear(options.clearAfterMs) + } + + return true }, [defaults], ) diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts new file mode 100644 index 0000000..b212060 --- /dev/null +++ b/src/lib/clipboard.ts @@ -0,0 +1,75 @@ +export const SEED_CLIPBOARD_CLEAR_MS = 60_000 + +// Single space rather than empty string — empty selections turn +// execCommand('copy') into a no-op on some Chromium versions. +const CLEARED_CLIPBOARD_VALUE = ' ' + +// execCommand is deprecated but it's the only clipboard write that doesn't +// require document focus in Chrome extension pages — important for the +// scheduled clear, which fires from a setTimeout after the user has likely +// switched focus to another app (where navigator.clipboard rejects). +const writeViaExecCommand = (text: string): boolean => { + const mark = document.createElement('span') + mark.textContent = text + mark.style.all = 'unset' + mark.style.position = 'fixed' + mark.style.top = '0' + mark.style.clip = 'rect(0, 0, 0, 0)' + mark.style.whiteSpace = 'pre' + mark.style.userSelect = 'text' + + document.body.appendChild(mark) + + const selection = document.getSelection() + const previousRange = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null + + const range = document.createRange() + range.selectNodeContents(mark) + selection?.removeAllRanges() + selection?.addRange(range) + + let success = false + try { + success = document.execCommand('copy') + } catch { + // keep success = false + } + + selection?.removeAllRanges() + if (previousRange) selection?.addRange(previousRange) + document.body.removeChild(mark) + + return success +} + +// For user-gesture copies (button clicks): the document is focused, so prefer +// the modern async API and only fall back to execCommand if it rejects. +export const writeToClipboard = async (text: string): Promise => { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + return writeViaExecCommand(text) + } +} + +let pendingClearTimer: number | null = null + +export const cancelPendingClipboardClear = (): void => { + if (pendingClearTimer !== null) { + window.clearTimeout(pendingClearTimer) + pendingClearTimer = null + } +} + +// NOTE: timer lives in the page document — if torn down (popup closed, +// extension reloaded) before delayMs, the clear won't run (follow-up: +// chrome.alarms + offscreen). Uses execCommand since the doc is usually +// unfocused when this fires, where the async clipboard API rejects. +export const scheduleClipboardClear = (delayMs: number): void => { + cancelPendingClipboardClear() + pendingClearTimer = window.setTimeout(() => { + pendingClearTimer = null + writeViaExecCommand(CLEARED_CLIPBOARD_VALUE) + }, delayMs) +}