Add native context menu to credential list (#880)

This commit is contained in:
Leendert de Borst
2025-06-02 14:21:26 +02:00
committed by Leendert de Borst
parent 4a35a1a7d3
commit fbc085439c
7 changed files with 290 additions and 37 deletions

View File

@@ -43,7 +43,7 @@ export default function AddEditCredentialScreen() : React.ReactNode {
const webApi = useWebApi();
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const serviceNameRef = useRef<ValidatedFormFieldRef>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [isSaveDisabled, setIsSaveDisabled] = useState(false);
const { control, handleSubmit, setValue, watch } = useForm<Credential>({
@@ -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<void> => {
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 (
<>
<Stack.Screen options={{ title: isEditMode ? 'Edit Credential' : 'Add Credential' }} />
{(isLoading) && (
{(isSyncing) && (
<LoadingOverlay status={syncStatus} />
)}
<KeyboardAvoidingView

View File

@@ -24,6 +24,8 @@ import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import { useWebApi } from '@/context/WebApiContext';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ServiceUrlNotice } from '@/components/credentials/ServiceUrlNotice';
import LoadingOverlay from '@/components/LoadingOverlay';
import { useVaultMutate } from '@/hooks/useVaultMutate';
/**
* Credentials screen.
@@ -44,6 +46,8 @@ export default function CredentialsScreen() : React.ReactNode {
const [refreshing, setRefreshing] = useMinDurationLoading(false, 200);
const [serviceUrl, setServiceUrl] = useState<string | null>(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<void> => {
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 (
<ThemedContainer>
<ThemedContainer style={styles.container}>
{(isSyncing) && (
<LoadingOverlay status={syncStatus} />
)}
<CollapsibleHeader
title="Credentials"
scrollY={scrollY}
@@ -354,7 +381,7 @@ export default function CredentialsScreen() : React.ReactNode {
isLoadingCredentials ? (
<SkeletonLoader count={1} height={60} parts={2} />
) : (
<CredentialCard credential={item} />
<CredentialCard credential={item} onCredentialDelete={onCredentialDelete} />
)
}
ListEmptyComponent={
@@ -366,6 +393,7 @@ export default function CredentialsScreen() : React.ReactNode {
}
/>
</ThemedView>
{isLoading && <LoadingOverlay status={syncStatus || 'Deleting credential...'} />}
</ThemedContainer>
);
}

View File

@@ -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<void>;
};
/**
* 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<void> => {
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 (
<TouchableOpacity
style={styles.credentialCard}
onPress={() => {
Keyboard.dismiss();
router.push(`/(tabs)/credentials/${credential.Id}`);
}}
activeOpacity={0.7}
<ContextMenu
title="Credential Options"
actions={getMenuActions()}
onPress={handleContextMenuAction}
>
<View style={styles.credentialContent}>
<CredentialIcon logo={credential.Logo} style={styles.logo} />
<View style={styles.credentialInfo}>
<Text style={styles.serviceName}>
{getCredentialServiceName(credential)}
</Text>
<Text style={styles.credentialText}>
{getCredentialDisplayText(credential)}
</Text>
<TouchableOpacity
style={styles.credentialCard}
onPress={() => {
Keyboard.dismiss();
router.push(`/(tabs)/credentials/${credential.Id}`);
}}
activeOpacity={0.7}
>
<View style={styles.credentialContent}>
<CredentialIcon logo={credential.Logo} style={styles.logo} />
<View style={styles.credentialInfo}>
<Text style={styles.serviceName}>
{getCredentialServiceName(credential)}
</Text>
<Text style={styles.credentialText}>
{getCredentialDisplayText(credential)}
</Text>
</View>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</ContextMenu>
);
}

View File

@@ -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 = "<group>"; };
CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKitTests; sourceTree = "<group>"; };
CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUI; sourceTree = "<group>"; };
CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultModels; sourceTree = "<group>"; };
CEE909812DA548C7008D568F /* Autofill */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Autofill; sourceTree = "<group>"; };
CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultStoreKit;
sourceTree = "<group>";
};
CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultStoreKitTests;
sourceTree = "<group>";
};
CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultUI;
sourceTree = "<group>";
};
CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultModels;
sourceTree = "<group>";
};
CEE909812DA548C7008D568F /* Autofill */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = Autofill;
sourceTree = "<group>";
};
/* 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;

View File

@@ -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

View File

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

View File

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