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",