Files
orca/mobile/src/components/MobileSourceCopyButton.tsx
T
OrcaWinandOrcaWin 5ed3c8c072 feat(mobile): localize shared components, pull requests and diff review (#35532)
Moves ~600 strings in the shared components (modals, drawers, host cards, dictation,
accounts), the pull request sidebar and diff review to the translation API, with all
ten translations.

- Invalid account snapshots are detected by error type, not by matching English text
- Credit expiry wording uses separate keys for one and several credits instead of
  plural rules, which would pick the wrong sentence in Japanese or Russian
- The PR chip memo recomputes on a language change

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-28 18:17:58 -07:00

145 lines
4.9 KiB
TypeScript

import { useLayoutEffect, useRef, useState } from 'react'
import { Alert, Platform, Pressable, Text } from 'react-native'
import { Check, Copy } from 'lucide-react-native'
import { translate } from '../i18n/mobile-i18n'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { useClipboardWriter } from '../platform/clipboard'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
type CopyFeedback = { status: 'copied'; text: string } | { status: 'failed' }
export function MobileSourceCopyButton({
text,
accessibilityLabel,
partial = false
}: {
text: string
accessibilityLabel: string
partial?: boolean
}): React.JSX.Element {
useMobileColorScheme()
const t = useMobileTranslation()
const clipboard = useClipboardWriter()
const [feedback, setFeedback] = useState<CopyFeedback | null>(null)
const latestAttempt = useRef<{ sourceVersion: number } | null>(null)
const sourceVersion = useRef(0)
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
if (feedback?.status === 'copied' && feedback.text !== text) {
setFeedback(null)
}
useLayoutEffect(
() => () => {
latestAttempt.current = null
if (resetTimer.current !== null) {
clearTimeout(resetTimer.current)
resetTimer.current = null
}
},
[]
)
// Streaming invalidates success feedback, while the pending user action still owns failures.
useLayoutEffect(() => {
sourceVersion.current++
if (feedback?.status !== 'failed' && resetTimer.current !== null) {
clearTimeout(resetTimer.current)
resetTimer.current = null
}
}, [text])
const showFeedback = (result: CopyFeedback, attempt: { sourceVersion: number }) => {
setFeedback(result)
resetTimer.current = setTimeout(() => {
resetTimer.current = null
if (latestAttempt.current === attempt) {
setFeedback(null)
}
}, 1500)
}
const copy = async () => {
if (text.length === 0) {
return
}
const attempt = { sourceVersion: sourceVersion.current }
latestAttempt.current = attempt
if (resetTimer.current !== null) {
clearTimeout(resetTimer.current)
resetTimer.current = null
}
setFeedback(null)
try {
await clipboard.writeText(text)
} catch (error) {
if (latestAttempt.current !== attempt) {
return
}
if (Platform.OS === 'web') {
showFeedback({ status: 'failed' }, attempt)
} else {
Alert.alert(
translate('components.sourceCopyButton.copyFailed', 'Copy failed'),
error instanceof Error
? error.message
: translate(
'components.sourceCopyButton.clipboardRejected',
'The clipboard rejected the text.'
)
)
}
return
}
if (latestAttempt.current !== attempt || sourceVersion.current !== attempt.sourceVersion) {
return
}
showFeedback({ status: 'copied', text }, attempt)
}
const copied = feedback?.status === 'copied' && feedback.text === text
const failed = feedback?.status === 'failed'
const label = failed
? t('components.sourceCopyButton.failed', 'Failed')
: copied
? partial
? t('components.sourceCopyButton.copiedLoaded', 'Copied loaded')
: t('common.copied', 'Copied')
: partial
? t('components.sourceCopyButton.copyLoaded', 'Copy loaded')
: t('common.copy', 'Copy')
const failureLabel = t('components.sourceCopyButton.couldNotCopy', "Couldn't copy")
const Icon = copied ? Check : Copy
const iconColor = failed ? colors.statusRed : copied ? colors.statusGreen : colors.textMuted
return (
<Pressable
style={({ pressed }) => [styles.button, partial && styles.partial, pressed && styles.pressed]}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={failed ? failureLabel : accessibilityLabel}
accessibilityLiveRegion={failed ? 'polite' : undefined}
accessibilityValue={{ text: failed ? failureLabel : copied ? label : undefined }}
aria-valuetext={failed ? failureLabel : copied ? label : undefined}
accessibilityState={{ disabled: text.length === 0 }}
disabled={text.length === 0}
onPress={() => void copy()}
>
<Icon size={14} color={iconColor} />
{partial ? <Text style={styles.label}>{label}</Text> : null}
</Pressable>
)
}
const styles = themedStyleSheet(() => ({
button: {
width: 24,
height: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
borderRadius: radii.button
},
partial: { width: 128, height: 32, paddingHorizontal: spacing.sm },
pressed: { backgroundColor: colors.bgRaised },
label: { color: colors.textMuted, fontSize: typography.metaSize }
}))