From 6a945dd4000867407f4beb31a508895ad746aabd Mon Sep 17 00:00:00 2001 From: Ahmed Tarek Date: Fri, 15 May 2026 02:41:51 +0300 Subject: [PATCH 1/2] feat: auto-clear revealed seed from clipboard after 60s --- .../onboarding/seed-security-step.tsx | 2 + .../manage-accounts/reveal-seed-drawer.tsx | 2 + src/hooks/use-clipboard-copy.ts | 59 ++++++++++------ src/lib/clipboard.ts | 69 +++++++++++++++++++ 4 files changed, 110 insertions(+), 22 deletions(-) create mode 100644 src/lib/clipboard.ts diff --git a/src/components/onboarding/seed-security-step.tsx b/src/components/onboarding/seed-security-step.tsx index 840ee26c..5b2e8eba 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 aaef1d56..50a86b96 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 1127794e..3c61ce83 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,10 @@ 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 { + cancelPendingClipboardClear() + + const success = await writeToClipboard(value) + if (!success) { if (messages.errorTitle) { toast.error(messages.errorTitle, { description: messages.errorDescription, @@ -58,6 +50,29 @@ 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) + } + + 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 00000000..d60a3de9 --- /dev/null +++ b/src/lib/clipboard.ts @@ -0,0 +1,69 @@ +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 when this +// runs from a setTimeout after the user has switched apps. +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 +} + +export const writeToClipboard = async (text: string): Promise => { + if (writeViaExecCommand(text)) return true + try { + await navigator.clipboard.writeText(text) + return true + } catch { + return false + } +} + +let pendingClearTimer: number | null = null + +export const cancelPendingClipboardClear = (): void => { + if (pendingClearTimer !== null) { + window.clearTimeout(pendingClearTimer) + pendingClearTimer = null + } +} + +export const scheduleClipboardClear = (delayMs: number): void => { + cancelPendingClipboardClear() + pendingClearTimer = window.setTimeout(() => { + pendingClearTimer = null + void writeToClipboard(CLEARED_CLIPBOARD_VALUE) + }, delayMs) +} From 5fd3217bedc8c5ccfab70dab2f7627c31f30db4c Mon Sep 17 00:00:00 2001 From: Ahmed Tarek Date: Tue, 2 Jun 2026 00:31:42 +0300 Subject: [PATCH 2/2] fix: prevent failed copy from stranding seed on clipboard --- src/hooks/use-clipboard-copy.ts | 3 +-- src/lib/clipboard.ts | 16 +++++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/hooks/use-clipboard-copy.ts b/src/hooks/use-clipboard-copy.ts index 3c61ce83..89c56d81 100644 --- a/src/hooks/use-clipboard-copy.ts +++ b/src/hooks/use-clipboard-copy.ts @@ -39,8 +39,6 @@ export const useClipboardCopy = (defaults?: CopyMessages) => { async (value: string, options?: CopyOptions) => { const messages = { ...defaults, ...options?.messages } - cancelPendingClipboardClear() - const success = await writeToClipboard(value) if (!success) { if (messages.errorTitle) { @@ -68,6 +66,7 @@ export const useClipboardCopy = (defaults?: CopyMessages) => { }, COPIED_KEY_INDICATOR_MS) } + cancelPendingClipboardClear() if (options?.clearAfterMs) { scheduleClipboardClear(options.clearAfterMs) } diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts index d60a3de9..b2120606 100644 --- a/src/lib/clipboard.ts +++ b/src/lib/clipboard.ts @@ -5,8 +5,9 @@ export const SEED_CLIPBOARD_CLEAR_MS = 60_000 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 when this -// runs from a setTimeout after the user has switched apps. +// 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 @@ -41,13 +42,14 @@ const writeViaExecCommand = (text: string): boolean => { 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 => { - if (writeViaExecCommand(text)) return true try { await navigator.clipboard.writeText(text) return true } catch { - return false + return writeViaExecCommand(text) } } @@ -60,10 +62,14 @@ export const cancelPendingClipboardClear = (): void => { } } +// 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 - void writeToClipboard(CLEARED_CLIPBOARD_VALUE) + writeViaExecCommand(CLEARED_CLIPBOARD_VALUE) }, delayMs) }