mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
* feat(mobile): give every corner one of three shapes Corners had drifted to 17 different radii, so a screen mixed 6 pt buttons, 14 pt cards, 12 pt groups and 4 pt badges. Every corner now comes from the shape of what it is: - control (12): anything you tap — buttons, icon buttons, fields, pickers, segmented controls, key caps. - surface (16): anything that holds content — cards, groups, sheets, banners, code boxes. Dialogs and the composer box use floating (24). - pill: badges, chips, status pills, dots, avatars and the composer's own round buttons. - tile (10) for icon squares and mark (4) for inline code and check squares; nested shapes take the parent's radius minus the gap. Every rounded style also asks for the continuous (squircle) corner curve; iOS draws it, Android and the web ignore it. * fix(mobile): keep circles and pills on the circular corner curve The continuous curve squares off a corner radius that reaches half the element's size, so the FAB, dots, avatars and the composer's round buttons stopped being round on iOS. * fix(mobile): put the anchored detail card on the surface radius * fix(mobile): keep the circular curve on unclipped bordered styles React Native strokes an unclipped visible border on a circular path while it fills the background with the continuous curve, so the two edges drifted apart at the corners.
145 lines
4.9 KiB
TypeScript
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.control
|
|
},
|
|
partial: { width: 128, height: 32, paddingHorizontal: spacing.sm },
|
|
pressed: { backgroundColor: colors.bgRaised },
|
|
label: { color: colors.textMuted, fontSize: typography.metaSize }
|
|
}))
|