Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/onboarding/seed-security-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -29,6 +30,7 @@ const SeedSecurityStep = ({
const handleCopy = () => {
copyText(seed, {
messages: { successTitle: t('onboarding.seedSecurity.seedCopied') },
clearAfterMs: SEED_CLIPBOARD_CLEAR_MS,
})
}

Expand Down
2 changes: 2 additions & 0 deletions src/components/pages/manage-accounts/reveal-seed-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
}

Expand Down
58 changes: 36 additions & 22 deletions src/hooks/use-clipboard-copy.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,16 +19,17 @@ type CopyMessages = {
type CopyOptions = {
key?: string
messages?: CopyMessages
clearAfterMs?: number
}

export const useClipboardCopy = (defaults?: CopyMessages) => {
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const timerRef = useRef<number | null>(null)
const indicatorTimerRef = useRef<number | null>(null)

useEffect(
() => () => {
if (timerRef.current) {
window.clearTimeout(timerRef.current)
if (indicatorTimerRef.current) {
window.clearTimeout(indicatorTimerRef.current)
}
},
[],
Expand All @@ -32,32 +39,39 @@ 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,
})
}
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],
)
Expand Down
75 changes: 75 additions & 0 deletions src/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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)
}
Loading