From fbc085439c7bbac7b2cddbd8b9a3e7e348f0b951 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 2 Jun 2025 14:21:26 +0200 Subject: [PATCH] Add native context menu to credential list (#880) --- .../app/(tabs)/credentials/add-edit.tsx | 21 +- .../app/(tabs)/credentials/index.tsx | 32 ++- .../components/credentials/CredentialCard.tsx | 183 ++++++++++++++++-- .../ios/AliasVault.xcodeproj/project.pbxproj | 73 ++++++- apps/mobile-app/ios/Podfile.lock | 6 + apps/mobile-app/package-lock.json | 11 ++ apps/mobile-app/package.json | 1 + 7 files changed, 290 insertions(+), 37 deletions(-) diff --git a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx b/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx index 505229b72..b20b59004 100644 --- a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx @@ -43,7 +43,7 @@ export default function AddEditCredentialScreen() : React.ReactNode { const webApi = useWebApi(); const [isPasswordVisible, setIsPasswordVisible] = useState(false); const serviceNameRef = useRef(null); - const [isLoading, setIsLoading] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); const [isSaveDisabled, setIsSaveDisabled] = useState(false); const { control, handleSubmit, setValue, watch } = useForm({ @@ -122,6 +122,13 @@ export default function AddEditCredentialScreen() : React.ReactNode { setValue('ServiceUrl', decodedUrl); setValue('ServiceName', serviceName); } + + // On create mode, focus the service name field after a short delay to ensure the component is mounted + if (!isEditMode) { + setTimeout(() => { + serviceNameRef.current?.focus(); + }, 100); + } }, [id, isEditMode, serviceUrl, loadExistingCredential, setValue, authContext.isOffline, router]); /** @@ -217,7 +224,7 @@ export default function AddEditCredentialScreen() : React.ReactNode { Keyboard.dismiss(); - setIsLoading(true); + setIsSyncing(true); // Assemble the credential to save const credentialToSave: Credential = { @@ -308,9 +315,9 @@ export default function AddEditCredentialScreen() : React.ReactNode { }); }, 200); - setIsLoading(false); + setIsSyncing(false); } - }, [isEditMode, id, serviceUrl, router, executeVaultMutation, dbContext.sqliteClient, mode, generateRandomAlias, webApi, watch, setIsSaveDisabled, setIsLoading, isSaveDisabled]); + }, [isEditMode, id, serviceUrl, router, executeVaultMutation, dbContext.sqliteClient, mode, generateRandomAlias, webApi, watch, setIsSaveDisabled, setIsSyncing, isSaveDisabled]); /** * Generate a random username. @@ -374,7 +381,7 @@ export default function AddEditCredentialScreen() : React.ReactNode { * Delete the credential. */ onPress: async () : Promise => { - setIsLoading(true); + setIsSyncing(true); await executeVaultMutation(async () => { await dbContext.sqliteClient!.deleteCredentialById(id); @@ -389,7 +396,7 @@ export default function AddEditCredentialScreen() : React.ReactNode { }); }, 200); - setIsLoading(false); + setIsSyncing(false); /* * Navigate back to the root of the navigation stack. @@ -545,7 +552,7 @@ export default function AddEditCredentialScreen() : React.ReactNode { return ( <> - {(isLoading) && ( + {(isSyncing) && ( )} (null); const insets = useSafeAreaInsets(); + const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate(); + const [isSyncing, setIsSyncing] = useState(false); const authContext = useAuth(); const dbContext = useDb(); @@ -222,8 +226,12 @@ export default function CredentialsScreen() : React.ReactNode { color: colors.textMuted, fontSize: 20, }, + container: { + paddingHorizontal: 0, + }, contentContainer: { paddingBottom: Platform.OS === 'ios' ? insets.bottom + 60 : 10, + paddingHorizontal: 14, paddingTop: Platform.OS === 'ios' ? 42 : 16, }, emptyText: { @@ -270,6 +278,22 @@ export default function CredentialsScreen() : React.ReactNode { }); }, [navigation, headerButtons]); + /** + * Delete a credential. + */ + const onCredentialDelete = useCallback(async (credentialId: string) : Promise => { + setIsSyncing(true); + + await executeVaultMutation(async () => { + await dbContext.sqliteClient!.deleteCredentialById(credentialId); + setIsSyncing(false); + }); + + // Refresh list after deletion with a small delay to ensure feedback is visible. + await new Promise(resolve => setTimeout(resolve, 250)); + await loadCredentials(); + }, [dbContext.sqliteClient, executeVaultMutation, loadCredentials]); + // Handle deep link parameters useFocusEffect( useCallback(() => { @@ -280,7 +304,10 @@ export default function CredentialsScreen() : React.ReactNode { ); return ( - + + {(isSyncing) && ( + + )} ) : ( - + ) } ListEmptyComponent={ @@ -366,6 +393,7 @@ export default function CredentialsScreen() : React.ReactNode { } /> + {isLoading && } ); } \ No newline at end of file diff --git a/apps/mobile-app/components/credentials/CredentialCard.tsx b/apps/mobile-app/components/credentials/CredentialCard.tsx index f9dbeceb0..e61866446 100644 --- a/apps/mobile-app/components/credentials/CredentialCard.tsx +++ b/apps/mobile-app/components/credentials/CredentialCard.tsx @@ -1,5 +1,8 @@ -import { StyleSheet, View, Text, TouchableOpacity, Keyboard } from 'react-native'; +import { StyleSheet, View, Text, TouchableOpacity, Keyboard, Platform, Alert } from 'react-native'; import { router } from 'expo-router'; +import ContextMenu, { OnPressMenuItemEvent } from 'react-native-context-menu-view'; +import * as Clipboard from 'expo-clipboard'; +import Toast from 'react-native-toast-message'; import { CredentialIcon } from '@/components/credentials/CredentialIcon'; import { useColors } from '@/hooks/useColorScheme'; @@ -7,12 +10,13 @@ import { Credential } from '@/utils/types/Credential'; type CredentialCardProps = { credential: Credential; + onCredentialDelete?: (credentialId: string) => Promise; }; /** * Credential card component. */ -export function CredentialCard({ credential }: CredentialCardProps) : React.ReactNode { +export function CredentialCard({ credential, onCredentialDelete }: CredentialCardProps) : React.ReactNode { const colors = useColors(); /** @@ -50,6 +54,139 @@ export function CredentialCard({ credential }: CredentialCardProps) : React.Reac return returnValue.length > 33 ? returnValue.slice(0, 30) + '...' : returnValue; }; + /** + * Handles the context menu action when an item is selected. + * @param event - The event object containing the selected action details + */ + const handleContextMenuAction = (event: OnPressMenuItemEvent): void => { + const { name } = event.nativeEvent; + + switch (name) { + case 'Edit': + Keyboard.dismiss(); + router.push({ + pathname: '/(tabs)/credentials/add-edit', + params: { id: credential.Id } + }); + break; + case 'Delete': + Keyboard.dismiss(); + Alert.alert( + "Delete Credential", + "Are you sure you want to delete this credential? This action cannot be undone.", + [ + { + text: "Cancel", + style: "cancel" + }, + { + text: "Delete", + style: "destructive", + /** + * Handles the delete credential action. + */ + onPress: async () : Promise => { + if (onCredentialDelete) { + await onCredentialDelete(credential.Id); + } + } + } + ] + ); + break; + case 'Copy Username': + if (credential.Username) { + Clipboard.setStringAsync(credential.Username); + Toast.show({ + type: 'success', + text1: 'Username copied to clipboard', + position: 'bottom', + }); + } + break; + case 'Copy Email': + if (credential.Alias?.Email) { + Clipboard.setStringAsync(credential.Alias.Email); + Toast.show({ + type: 'success', + text1: 'Email copied to clipboard', + position: 'bottom', + }); + } + break; + case 'Copy Password': + if (credential.Password) { + Clipboard.setStringAsync(credential.Password); + Toast.show({ + type: 'success', + text1: 'Password copied to clipboard', + position: 'bottom', + }); + } + break; + } + }; + + /** + * Gets the menu actions for the context menu based on available credential data. + * @returns Array of menu action objects with title and icon + */ + const getMenuActions = (): { + title: string; + systemIcon: string; + destructive?: boolean; + }[] => { + const actions = [ + { + title: 'Edit', + systemIcon: Platform.select({ + ios: 'pencil', + android: 'baseline_edit', + }), + }, + { + title: 'Delete', + systemIcon: Platform.select({ + ios: 'trash', + android: 'baseline_delete', + }), + destructive: true, + }, + ]; + + if (credential.Username) { + actions.push({ + title: 'Copy Username', + systemIcon: Platform.select({ + ios: 'person', + android: 'baseline_person', + }), + }); + } + + if (credential.Alias?.Email) { + actions.push({ + title: 'Copy Email', + systemIcon: Platform.select({ + ios: 'envelope', + android: 'baseline_email', + }), + }); + } + + if (credential.Password) { + actions.push({ + title: 'Copy Password', + systemIcon: Platform.select({ + ios: 'key', + android: 'baseline_key', + }), + }); + } + + return actions; + }; + const styles = StyleSheet.create({ credentialCard: { backgroundColor: colors.accentBackground, @@ -83,25 +220,31 @@ export function CredentialCard({ credential }: CredentialCardProps) : React.Reac }); return ( - { - Keyboard.dismiss(); - router.push(`/(tabs)/credentials/${credential.Id}`); - }} - activeOpacity={0.7} + - - - - - {getCredentialServiceName(credential)} - - - {getCredentialDisplayText(credential)} - + { + Keyboard.dismiss(); + router.push(`/(tabs)/credentials/${credential.Id}`); + }} + activeOpacity={0.7} + > + + + + + {getCredentialServiceName(credential)} + + + {getCredentialDisplayText(credential)} + + - - + + ); } diff --git a/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj b/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj index dc29c3501..300541119 100644 --- a/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj +++ b/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj @@ -186,7 +186,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, @@ -196,11 +196,62 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKit; sourceTree = ""; }; - CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKitTests; sourceTree = ""; }; - CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUI; sourceTree = ""; }; - CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultModels; sourceTree = ""; }; - CEE909812DA548C7008D568F /* Autofill */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Autofill; sourceTree = ""; }; + CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = VaultStoreKit; + sourceTree = ""; + }; + CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = VaultStoreKitTests; + sourceTree = ""; + }; + CEE4816B2DBE8AC800F4A367 /* VaultUI */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = VaultUI; + sourceTree = ""; + }; + CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = VaultModels; + sourceTree = ""; + }; + CEE909812DA548C7008D568F /* Autofill */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = Autofill; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1157,7 +1208,10 @@ LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -1211,7 +1265,10 @@ ); LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = NO; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/apps/mobile-app/ios/Podfile.lock b/apps/mobile-app/ios/Podfile.lock index ee3d24298..07e9dd0b2 100644 --- a/apps/mobile-app/ios/Podfile.lock +++ b/apps/mobile-app/ios/Podfile.lock @@ -1565,6 +1565,8 @@ PODS: - Yoga - react-native-aes-gcm-crypto (0.2.2): - React-Core + - react-native-context-menu-view (1.19.0): + - React - react-native-get-random-values (1.11.0): - React-Core - react-native-quick-crypto (0.7.13): @@ -2277,6 +2279,7 @@ DEPENDENCIES: - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - react-native-aes-gcm-crypto (from `../node_modules/react-native-aes-gcm-crypto`) + - react-native-context-menu-view (from `../node_modules/react-native-context-menu-view`) - react-native-get-random-values (from `../node_modules/react-native-get-random-values`) - react-native-quick-crypto (from `../node_modules/react-native-quick-crypto`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) @@ -2458,6 +2461,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" react-native-aes-gcm-crypto: :path: "../node_modules/react-native-aes-gcm-crypto" + react-native-context-menu-view: + :path: "../node_modules/react-native-context-menu-view" react-native-get-random-values: :path: "../node_modules/react-native-get-random-values" react-native-quick-crypto: @@ -2603,6 +2608,7 @@ SPEC CHECKSUMS: React-Mapbuffer: 33546a3ebefbccb8770c33a1f8a5554fa96a54de React-microtasksnativemodule: d80ff86c8902872d397d9622f1a97aadcc12cead react-native-aes-gcm-crypto: d572dd7a69f31c539bb8309b3a829bfa3bfad244 + react-native-context-menu-view: 3a8fb510448efa9d477f645dafa889ef1c78daaa react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba react-native-quick-crypto: 361ecda861e23138ce68fea4b820cbc058688fb5 react-native-safe-area-context: cd916088cac5300c3266876218377518987b995e diff --git a/apps/mobile-app/package-lock.json b/apps/mobile-app/package-lock.json index 9e19fa3f6..70b286bcc 100644 --- a/apps/mobile-app/package-lock.json +++ b/apps/mobile-app/package-lock.json @@ -40,6 +40,7 @@ "react-native": "0.76.9", "react-native-aes-gcm-crypto": "^0.2.2", "react-native-argon2": "^2.0.1", + "react-native-context-menu-view": "^1.19.0", "react-native-edge-to-edge": "^1.6.0", "react-native-gesture-handler": "~2.20.2", "react-native-get-random-values": "^1.11.0", @@ -17849,6 +17850,16 @@ "integrity": "sha512-/iOi0S+VVgS1gQGtQgL4ZxUVS4gz6Lav3bgIbtNmr9KbOunnBYzP6/yBe/XxkbpXvasHDwdQnuppOH/nuOBn7w==", "license": "MIT" }, + "node_modules/react-native-context-menu-view": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/react-native-context-menu-view/-/react-native-context-menu-view-1.19.0.tgz", + "integrity": "sha512-RKkDUQuY9cVIb3rJl0ch8+FLH/WnjN6febtOuSif/6F3q7vuMZ8Ie+jmYGtnIbvSxl6lMknAZU61O192ysW3zg==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-native": ">=0.60.0-rc.0 <1.0.x" + } + }, "node_modules/react-native-edge-to-edge": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/react-native-edge-to-edge/-/react-native-edge-to-edge-1.6.0.tgz", diff --git a/apps/mobile-app/package.json b/apps/mobile-app/package.json index ea1104fea..a979f6a63 100644 --- a/apps/mobile-app/package.json +++ b/apps/mobile-app/package.json @@ -61,6 +61,7 @@ "react-native": "0.76.9", "react-native-aes-gcm-crypto": "^0.2.2", "react-native-argon2": "^2.0.1", + "react-native-context-menu-view": "^1.19.0", "react-native-edge-to-edge": "^1.6.0", "react-native-gesture-handler": "~2.20.2", "react-native-get-random-values": "^1.11.0",