diff --git a/apps/mobile-app/app/(tabs)/settings/index.tsx b/apps/mobile-app/app/(tabs)/settings/index.tsx index 1f0353274..5b117a63a 100644 --- a/apps/mobile-app/app/(tabs)/settings/index.tsx +++ b/apps/mobile-app/app/(tabs)/settings/index.tsx @@ -79,6 +79,8 @@ export default function SettingsScreen() : React.ReactNode { display = t('settings.clipboardClearOptions.10seconds'); } else if (clipboardTimeout === 15) { display = t('settings.clipboardClearOptions.15seconds'); + } else if (clipboardTimeout === 30) { + display = t('settings.clipboardClearOptions.30seconds'); } setClipboardClearDisplay(display); @@ -101,7 +103,7 @@ export default function SettingsScreen() : React.ReactNode { }; loadData(); - }, [getAutoLockTimeout, getAuthMethodDisplayKey, setIsFirstLoad, loadApiUrl, t]) + }, [getAutoLockTimeout, getAuthMethodDisplayKey, setIsFirstLoad, loadApiUrl, getClipboardClearTimeout, t]) ); /** diff --git a/apps/mobile-app/app/_layout.tsx b/apps/mobile-app/app/_layout.tsx index 66a98b981..aabc5102c 100644 --- a/apps/mobile-app/app/_layout.tsx +++ b/apps/mobile-app/app/_layout.tsx @@ -14,6 +14,7 @@ import SpaceMono from '@/assets/fonts/SpaceMono-Regular.ttf'; import { ThemedView } from '@/components/themed/ThemedView'; import { AliasVaultToast } from '@/components/Toast'; import { AuthProvider } from '@/context/AuthContext'; +import { ClipboardCountdownProvider } from '@/context/ClipboardCountdownContext'; import { DbProvider } from '@/context/DbContext'; import { WebApiProvider } from '@/context/WebApiContext'; import { initI18n } from '@/i18n'; @@ -182,7 +183,9 @@ export default function RootLayout() : React.ReactNode { - + + + diff --git a/apps/mobile-app/components/form/FormInputCopyToClipboard.tsx b/apps/mobile-app/components/form/FormInputCopyToClipboard.tsx index 8d9fe9474..40b847c0b 100644 --- a/apps/mobile-app/components/form/FormInputCopyToClipboard.tsx +++ b/apps/mobile-app/components/form/FormInputCopyToClipboard.tsx @@ -8,6 +8,7 @@ import Toast from 'react-native-toast-message'; import { useColors } from '@/hooks/useColorScheme'; +import { useClipboardCountdown } from '@/context/ClipboardCountdownContext'; import NativeVaultManager from '@/specs/NativeVaultManager'; type FormInputCopyToClipboardProps = { @@ -27,16 +28,50 @@ const FormInputCopyToClipboard: React.FC = ({ const [isPasswordVisible, setIsPasswordVisible] = useState(false); const colors = useColors(); const { t } = useTranslation(); + const { activeFieldId, setActiveField } = useClipboardCountdown(); const animatedWidth = useRef(new Animated.Value(0)).current; - const [isCountingDown, setIsCountingDown] = useState(false); + // Create a stable unique ID based on label and value + const fieldId = useRef(`${label}-${value}-${Math.random().toString(36).substring(2, 11)}`).current; + const isCountingDown = activeFieldId === fieldId; useEffect(() => { - return () => { + return (): void => { + // Cleanup on unmount animatedWidth.stopAnimation(); }; }, [animatedWidth]); + useEffect(() => { + /* Handle animation based on whether this field is active */ + if (isCountingDown) { + // This field is now active - reset and start animation + animatedWidth.stopAnimation(); + animatedWidth.setValue(100); + + // Get timeout and start animation + AsyncStorage.getItem('clipboard_clear_timeout').then((timeoutStr) => { + const timeoutSeconds = timeoutStr ? parseInt(timeoutStr, 10) : 10; + if (timeoutSeconds > 0 && activeFieldId === fieldId) { + Animated.timing(animatedWidth, { + toValue: 0, + duration: timeoutSeconds * 1000, + useNativeDriver: false, + easing: Easing.linear, + }).start((finished) => { + if (finished && activeFieldId === fieldId) { + setActiveField(null); + } + }); + } + }); + } else { + // This field is not active - stop animation and reset + animatedWidth.stopAnimation(); + animatedWidth.setValue(0); + } + }, [isCountingDown, activeFieldId, fieldId, animatedWidth, setActiveField]); + /** * Copy the value to the clipboard. */ @@ -52,20 +87,19 @@ const FormInputCopyToClipboard: React.FC = ({ // Schedule clipboard clear if timeout is set if (timeoutSeconds > 0) { + // Clear any existing active field first (this will cancel its animation) + setActiveField(null); + + // Schedule the clipboard clear await NativeVaultManager.clearClipboardAfterDelay(timeoutSeconds); - // Start countdown animation - setIsCountingDown(true); - animatedWidth.setValue(100); - - Animated.timing(animatedWidth, { - toValue: 0, - duration: timeoutSeconds * 1000, - useNativeDriver: false, - easing: Easing.linear, - }).start(() => { - setIsCountingDown(false); - }); + /* + * Now set this field as active - animation will be handled by the effect + * Use setTimeout to ensure state update happens in next tick + */ + setTimeout(() => { + setActiveField(fieldId); + }, 0); } if (Platform.OS !== 'android') { @@ -98,6 +132,14 @@ const FormInputCopyToClipboard: React.FC = ({ alignItems: 'center', flexDirection: 'row', }, + animatedOverlay: { + backgroundColor: `${colors.primary}50`, + borderRadius: 8, + bottom: 0, + left: 0, + position: 'absolute', + top: 0, + }, iconButton: { padding: 8, }, @@ -105,12 +147,14 @@ const FormInputCopyToClipboard: React.FC = ({ backgroundColor: colors.accentBackground, borderRadius: 8, marginBottom: 8, - padding: 12, + overflow: 'hidden', + position: 'relative', }, inputContent: { alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between', + padding: 12, }, label: { color: colors.textMuted, @@ -122,14 +166,6 @@ const FormInputCopyToClipboard: React.FC = ({ fontSize: 16, fontWeight: '500', }, - animatedOverlay: { - position: 'absolute', - top: 0, - left: 0, - bottom: 0, - backgroundColor: `${colors.primary}50`, - borderRadius: 8, - }, }); return ( diff --git a/apps/mobile-app/context/ClipboardCountdownContext.tsx b/apps/mobile-app/context/ClipboardCountdownContext.tsx new file mode 100644 index 000000000..40377b033 --- /dev/null +++ b/apps/mobile-app/context/ClipboardCountdownContext.tsx @@ -0,0 +1,42 @@ +import React, { createContext, useContext, useState, useCallback, useMemo } from 'react'; + +type ClipboardCountdownContextType = { + activeFieldId: string | null; + setActiveField: (fieldId: string | null | ((prev: string | null) => string | null)) => void; +} + +const ClipboardCountdownContext = createContext(undefined); + +/** + * Clipboard countdown context provider. + */ +export const ClipboardCountdownProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [activeFieldId, setActiveFieldId] = useState(null); + + const setActiveField = useCallback((fieldId: string | null | ((prev: string | null) => string | null)) => { + if (typeof fieldId === 'function') { + setActiveFieldId(fieldId); + } else { + setActiveFieldId(fieldId); + } + }, []); + + const value = useMemo(() => ({ activeFieldId, setActiveField }), [activeFieldId, setActiveField]); + + return ( + + {children} + + ); +}; + +/** + * Clipboard countdown context hook. + */ +export const useClipboardCountdown = (): ClipboardCountdownContextType => { + const context = useContext(ClipboardCountdownContext); + if (!context) { + throw new Error('useClipboardCountdown must be used within ClipboardCountdownProvider'); + } + return context; +}; diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index 7d66ab8eb..38d8eda4c 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -199,7 +199,8 @@ "never": "Never", "5seconds": "5 seconds", "10seconds": "10 seconds", - "15seconds": "15 seconds" + "15seconds": "15 seconds", + "30seconds": "30 seconds" }, "identityGenerator": "Identity Generator", "security": "Security",