Initial Commit
This commit is contained in:
671
docs/changes/mobile-optimization-v1/04-STATE-MANAGEMENT.md
Normal file
671
docs/changes/mobile-optimization-v1/04-STATE-MANAGEMENT.md
Normal file
@@ -0,0 +1,671 @@
|
||||
# State Management & Navigation Consistency Solutions
|
||||
|
||||
## Overview
|
||||
This document addresses critical state management issues in mobile navigation, including context loss during screen transitions, form state persistence, and navigation consistency between mobile and desktop platforms.
|
||||
|
||||
## Issues Identified
|
||||
|
||||
### 1. Mobile State Reset Issues
|
||||
**Location**: `frontend/src/App.tsx` mobile navigation logic
|
||||
|
||||
**Problem**: Navigation between screens resets critical state:
|
||||
- `selectedVehicle` resets when switching screens
|
||||
- `showAddVehicle` form state lost during navigation
|
||||
- User context not maintained across screen transitions
|
||||
- Mobile navigation doesn't preserve history
|
||||
|
||||
### 2. Navigation Paradigm Split
|
||||
**Mobile**: State-based navigation without URLs (`activeScreen` state)
|
||||
**Desktop**: URL-based routing with React Router
|
||||
**Impact**: Inconsistent user experience and different development patterns
|
||||
|
||||
### 3. State Persistence Gaps
|
||||
- User context not persisted (requires re-authentication overhead)
|
||||
- Form data lost when navigating away
|
||||
- Mobile navigation state not preserved across app restarts
|
||||
- Settings changes not immediately reflected across screens
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
### Enhanced Mobile State Management
|
||||
|
||||
#### 1. Navigation State Persistence
|
||||
**File**: `frontend/src/core/store/navigation.ts` (new)
|
||||
|
||||
```tsx
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type MobileScreen = 'dashboard' | 'vehicles' | 'fuel' | 'settings';
|
||||
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
|
||||
|
||||
interface NavigationState {
|
||||
// Current navigation state
|
||||
activeScreen: MobileScreen;
|
||||
vehicleSubScreen: VehicleSubScreen;
|
||||
selectedVehicleId: string | null;
|
||||
|
||||
// Navigation history for back button
|
||||
navigationHistory: {
|
||||
screen: MobileScreen;
|
||||
vehicleSubScreen?: VehicleSubScreen;
|
||||
selectedVehicleId?: string | null;
|
||||
timestamp: number;
|
||||
}[];
|
||||
|
||||
// Form state preservation
|
||||
formStates: Record<string, any>;
|
||||
|
||||
// Actions
|
||||
navigateToScreen: (screen: MobileScreen) => void;
|
||||
navigateToVehicleSubScreen: (subScreen: VehicleSubScreen, vehicleId?: string) => void;
|
||||
goBack: () => void;
|
||||
saveFormState: (formId: string, state: any) => void;
|
||||
restoreFormState: (formId: string) => any;
|
||||
clearFormState: (formId: string) => void;
|
||||
}
|
||||
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
activeScreen: 'vehicles',
|
||||
vehicleSubScreen: 'list',
|
||||
selectedVehicleId: null,
|
||||
navigationHistory: [],
|
||||
formStates: {},
|
||||
|
||||
// Navigation actions
|
||||
navigateToScreen: (screen) => {
|
||||
const currentState = get();
|
||||
const historyEntry = {
|
||||
screen: currentState.activeScreen,
|
||||
vehicleSubScreen: currentState.vehicleSubScreen,
|
||||
selectedVehicleId: currentState.selectedVehicleId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
set({
|
||||
activeScreen: screen,
|
||||
vehicleSubScreen: screen === 'vehicles' ? 'list' : currentState.vehicleSubScreen,
|
||||
selectedVehicleId: screen === 'vehicles' ? currentState.selectedVehicleId : null,
|
||||
navigationHistory: [...currentState.navigationHistory, historyEntry].slice(-10), // Keep last 10
|
||||
});
|
||||
},
|
||||
|
||||
navigateToVehicleSubScreen: (subScreen, vehicleId = null) => {
|
||||
const currentState = get();
|
||||
const historyEntry = {
|
||||
screen: currentState.activeScreen,
|
||||
vehicleSubScreen: currentState.vehicleSubScreen,
|
||||
selectedVehicleId: currentState.selectedVehicleId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
set({
|
||||
vehicleSubScreen: subScreen,
|
||||
selectedVehicleId: vehicleId || currentState.selectedVehicleId,
|
||||
navigationHistory: [...currentState.navigationHistory, historyEntry].slice(-10),
|
||||
});
|
||||
},
|
||||
|
||||
goBack: () => {
|
||||
const currentState = get();
|
||||
const lastEntry = currentState.navigationHistory[currentState.navigationHistory.length - 1];
|
||||
|
||||
if (lastEntry) {
|
||||
set({
|
||||
activeScreen: lastEntry.screen,
|
||||
vehicleSubScreen: lastEntry.vehicleSubScreen || 'list',
|
||||
selectedVehicleId: lastEntry.selectedVehicleId,
|
||||
navigationHistory: currentState.navigationHistory.slice(0, -1),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Form state management
|
||||
saveFormState: (formId, state) => {
|
||||
set((current) => ({
|
||||
formStates: {
|
||||
...current.formStates,
|
||||
[formId]: { ...state, timestamp: Date.now() },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
restoreFormState: (formId) => {
|
||||
const state = get().formStates[formId];
|
||||
// Return state if it's less than 1 hour old
|
||||
if (state && Date.now() - state.timestamp < 3600000) {
|
||||
return state;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
clearFormState: (formId) => {
|
||||
set((current) => {
|
||||
const newFormStates = { ...current.formStates };
|
||||
delete newFormStates[formId];
|
||||
return { formStates: newFormStates };
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'motovaultpro-mobile-navigation',
|
||||
partialize: (state) => ({
|
||||
activeScreen: state.activeScreen,
|
||||
vehicleSubScreen: state.vehicleSubScreen,
|
||||
selectedVehicleId: state.selectedVehicleId,
|
||||
formStates: state.formStates,
|
||||
// Don't persist navigation history - rebuild on app start
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
#### 2. Enhanced User Context Persistence
|
||||
**File**: `frontend/src/core/store/user.ts` (new)
|
||||
|
||||
```tsx
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface UserPreferences {
|
||||
unitSystem: 'imperial' | 'metric';
|
||||
darkMode: boolean;
|
||||
notifications: {
|
||||
email: boolean;
|
||||
push: boolean;
|
||||
maintenance: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
// User data (persisted subset)
|
||||
userProfile: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
picture: string;
|
||||
} | null;
|
||||
|
||||
preferences: UserPreferences;
|
||||
|
||||
// Session data (not persisted)
|
||||
isOnline: boolean;
|
||||
lastSyncTimestamp: number;
|
||||
|
||||
// Actions
|
||||
setUserProfile: (profile: any) => void;
|
||||
updatePreferences: (preferences: Partial<UserPreferences>) => void;
|
||||
setOnlineStatus: (isOnline: boolean) => void;
|
||||
updateLastSync: () => void;
|
||||
clearUserData: () => void;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Initial state
|
||||
userProfile: null,
|
||||
preferences: {
|
||||
unitSystem: 'imperial',
|
||||
darkMode: false,
|
||||
notifications: {
|
||||
email: true,
|
||||
push: true,
|
||||
maintenance: true,
|
||||
},
|
||||
},
|
||||
isOnline: true,
|
||||
lastSyncTimestamp: 0,
|
||||
|
||||
// Actions
|
||||
setUserProfile: (profile) => {
|
||||
if (profile) {
|
||||
set({
|
||||
userProfile: {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
picture: profile.picture,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updatePreferences: (newPreferences) => {
|
||||
set((state) => ({
|
||||
preferences: { ...state.preferences, ...newPreferences },
|
||||
}));
|
||||
},
|
||||
|
||||
setOnlineStatus: (isOnline) => set({ isOnline }),
|
||||
|
||||
updateLastSync: () => set({ lastSyncTimestamp: Date.now() }),
|
||||
|
||||
clearUserData: () => set({
|
||||
userProfile: null,
|
||||
preferences: {
|
||||
unitSystem: 'imperial',
|
||||
darkMode: false,
|
||||
notifications: {
|
||||
email: true,
|
||||
push: true,
|
||||
maintenance: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'motovaultpro-user-context',
|
||||
partialize: (state) => ({
|
||||
userProfile: state.userProfile,
|
||||
preferences: state.preferences,
|
||||
// Don't persist session data
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
#### 3. Smart Form State Hook
|
||||
**File**: `frontend/src/core/hooks/useFormState.ts` (new)
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigationStore } from '../store/navigation';
|
||||
|
||||
export interface UseFormStateOptions {
|
||||
formId: string;
|
||||
defaultValues: Record<string, any>;
|
||||
autoSave?: boolean;
|
||||
saveDelay?: number;
|
||||
}
|
||||
|
||||
export const useFormState = <T extends Record<string, any>>({
|
||||
formId,
|
||||
defaultValues,
|
||||
autoSave = true,
|
||||
saveDelay = 1000,
|
||||
}: UseFormStateOptions) => {
|
||||
const { saveFormState, restoreFormState, clearFormState } = useNavigationStore();
|
||||
const [formData, setFormData] = useState<T>(defaultValues as T);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isRestored, setIsRestored] = useState(false);
|
||||
|
||||
// Restore form state on mount
|
||||
useEffect(() => {
|
||||
const restoredState = restoreFormState(formId);
|
||||
if (restoredState && !isRestored) {
|
||||
setFormData({ ...defaultValues, ...restoredState });
|
||||
setHasChanges(true);
|
||||
setIsRestored(true);
|
||||
}
|
||||
}, [formId, restoreFormState, defaultValues, isRestored]);
|
||||
|
||||
// Auto-save with debounce
|
||||
useEffect(() => {
|
||||
if (!autoSave || !hasChanges) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
saveFormState(formId, formData);
|
||||
}, saveDelay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [formData, hasChanges, autoSave, saveDelay, formId, saveFormState]);
|
||||
|
||||
const updateFormData = useCallback((updates: Partial<T>) => {
|
||||
setFormData((current) => ({ ...current, ...updates }));
|
||||
setHasChanges(true);
|
||||
}, []);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData(defaultValues as T);
|
||||
setHasChanges(false);
|
||||
clearFormState(formId);
|
||||
}, [defaultValues, formId, clearFormState]);
|
||||
|
||||
const submitForm = useCallback(() => {
|
||||
setHasChanges(false);
|
||||
clearFormState(formId);
|
||||
}, [formId, clearFormState]);
|
||||
|
||||
return {
|
||||
formData,
|
||||
updateFormData,
|
||||
resetForm,
|
||||
submitForm,
|
||||
hasChanges,
|
||||
isRestored,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Implementation in App.tsx
|
||||
|
||||
#### Updated Mobile Navigation Logic
|
||||
**File**: `frontend/src/App.tsx` (modifications)
|
||||
|
||||
```tsx
|
||||
import { useNavigationStore } from './core/store/navigation';
|
||||
import { useUserStore } from './core/store/user';
|
||||
|
||||
// Replace existing mobile detection and state management
|
||||
const MobileApp: React.FC = () => {
|
||||
const { user, isAuthenticated } = useAuth0();
|
||||
const {
|
||||
activeScreen,
|
||||
vehicleSubScreen,
|
||||
selectedVehicleId,
|
||||
navigateToScreen,
|
||||
navigateToVehicleSubScreen,
|
||||
goBack,
|
||||
} = useNavigationStore();
|
||||
|
||||
const { setUserProfile } = useUserStore();
|
||||
|
||||
// Update user profile when authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
setUserProfile(user);
|
||||
}
|
||||
}, [isAuthenticated, user, setUserProfile]);
|
||||
|
||||
// Handle mobile back button
|
||||
useEffect(() => {
|
||||
const handlePopState = (event: PopStateEvent) => {
|
||||
event.preventDefault();
|
||||
goBack();
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [goBack]);
|
||||
|
||||
const handleVehicleSelect = (vehicleId: string) => {
|
||||
navigateToVehicleSubScreen('detail', vehicleId);
|
||||
};
|
||||
|
||||
const handleAddVehicle = () => {
|
||||
navigateToVehicleSubScreen('add');
|
||||
};
|
||||
|
||||
const handleBackToList = () => {
|
||||
navigateToVehicleSubScreen('list');
|
||||
};
|
||||
|
||||
// Render screens based on navigation state
|
||||
const renderActiveScreen = () => {
|
||||
switch (activeScreen) {
|
||||
case 'vehicles':
|
||||
return renderVehiclesScreen();
|
||||
case 'fuel':
|
||||
return <FuelScreen />;
|
||||
case 'dashboard':
|
||||
return <DashboardScreen />;
|
||||
case 'settings':
|
||||
return <MobileSettingsScreen />;
|
||||
default:
|
||||
return renderVehiclesScreen();
|
||||
}
|
||||
};
|
||||
|
||||
const renderVehiclesScreen = () => {
|
||||
switch (vehicleSubScreen) {
|
||||
case 'list':
|
||||
return (
|
||||
<VehiclesMobileScreen
|
||||
onVehicleSelect={handleVehicleSelect}
|
||||
onAddVehicle={handleAddVehicle}
|
||||
/>
|
||||
);
|
||||
case 'detail':
|
||||
return (
|
||||
<VehicleDetailMobile
|
||||
vehicleId={selectedVehicleId!}
|
||||
onBack={handleBackToList}
|
||||
/>
|
||||
);
|
||||
case 'add':
|
||||
return (
|
||||
<AddVehicleScreen
|
||||
onBack={handleBackToList}
|
||||
onVehicleAdded={handleBackToList}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<VehiclesMobileScreen
|
||||
onVehicleSelect={handleVehicleSelect}
|
||||
onAddVehicle={handleAddVehicle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
{renderActiveScreen()}
|
||||
|
||||
<BottomNavigation
|
||||
activeScreen={activeScreen}
|
||||
onScreenChange={navigateToScreen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
#### Enhanced Add Vehicle Form with State Persistence
|
||||
**File**: `frontend/src/features/vehicles/mobile/AddVehicleScreen.tsx` (example usage)
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { useFormState } from '../../../core/hooks/useFormState';
|
||||
|
||||
interface AddVehicleScreenProps {
|
||||
onBack: () => void;
|
||||
onVehicleAdded: () => void;
|
||||
}
|
||||
|
||||
export const AddVehicleScreen: React.FC<AddVehicleScreenProps> = ({
|
||||
onBack,
|
||||
onVehicleAdded,
|
||||
}) => {
|
||||
const {
|
||||
formData,
|
||||
updateFormData,
|
||||
resetForm,
|
||||
submitForm,
|
||||
hasChanges,
|
||||
isRestored,
|
||||
} = useFormState({
|
||||
formId: 'add-vehicle',
|
||||
defaultValues: {
|
||||
year: '',
|
||||
make: '',
|
||||
model: '',
|
||||
trim: '',
|
||||
vin: '',
|
||||
licensePlate: '',
|
||||
nickname: '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
// Submit vehicle data
|
||||
await submitVehicle(formData);
|
||||
submitForm(); // Clear saved state
|
||||
onVehicleAdded();
|
||||
} catch (error) {
|
||||
// Handle error, form state is preserved
|
||||
console.error('Error adding vehicle:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center mb-6">
|
||||
<button onClick={onBack} className="mr-4">
|
||||
<ArrowLeft className="w-6 h-6" />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold">Add Vehicle</h1>
|
||||
{isRestored && (
|
||||
<span className="ml-auto text-sm text-blue-600">Draft restored</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Year"
|
||||
value={formData.year}
|
||||
onChange={(e) => updateFormData({ year: e.target.value })}
|
||||
className="w-full p-3 border rounded-lg"
|
||||
/>
|
||||
|
||||
{/* More form fields... */}
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="flex-1 py-3 bg-gray-200 text-gray-700 rounded-lg"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-3 bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
Add Vehicle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<p className="text-sm text-blue-600 text-center">
|
||||
Changes are being saved automatically
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Integration with Existing Systems
|
||||
|
||||
### 1. Zustand Store Integration
|
||||
**File**: `frontend/src/core/store/index.ts` (existing file modifications)
|
||||
|
||||
```tsx
|
||||
// Export new stores alongside existing ones
|
||||
export { useNavigationStore } from './navigation';
|
||||
export { useUserStore } from './user';
|
||||
|
||||
// Keep existing store exports
|
||||
export { useAppStore } from './app';
|
||||
```
|
||||
|
||||
### 2. Auth0 Integration Enhancement
|
||||
**File**: `frontend/src/core/auth/Auth0Provider.tsx` (modifications)
|
||||
|
||||
```tsx
|
||||
import { useUserStore } from '../store/user';
|
||||
|
||||
// Inside the Auth0Provider component
|
||||
const { setUserProfile, clearUserData } = useUserStore();
|
||||
|
||||
// Update user profile on authentication
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
setUserProfile(user);
|
||||
} else if (!isAuthenticated) {
|
||||
clearUserData();
|
||||
}
|
||||
}, [isAuthenticated, user, setUserProfile, clearUserData]);
|
||||
```
|
||||
|
||||
### 3. Unit System Integration
|
||||
**File**: `frontend/src/shared-minimal/utils/units.ts` (modifications)
|
||||
|
||||
```tsx
|
||||
import { useUserStore } from '../../core/store/user';
|
||||
|
||||
// Update existing unit hooks to use new store
|
||||
export const useUnitSystem = () => {
|
||||
const { preferences, updatePreferences } = useUserStore();
|
||||
|
||||
const toggleUnitSystem = () => {
|
||||
const newSystem = preferences.unitSystem === 'imperial' ? 'metric' : 'imperial';
|
||||
updatePreferences({ unitSystem: newSystem });
|
||||
};
|
||||
|
||||
return {
|
||||
unitSystem: preferences.unitSystem,
|
||||
toggleUnitSystem,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### State Persistence Tests
|
||||
- ✅ Navigation state persists across app restarts
|
||||
- ✅ Selected vehicle context maintained during navigation
|
||||
- ✅ Form state preserved when navigating away and returning
|
||||
- ✅ User preferences persist and sync across screens
|
||||
- ✅ Navigation history works correctly with back button
|
||||
|
||||
### Mobile Navigation Tests
|
||||
- ✅ Screen transitions maintain context
|
||||
- ✅ Bottom navigation reflects current state accurately
|
||||
- ✅ Add vehicle form preserves data during interruptions
|
||||
- ✅ Settings changes reflect immediately across screens
|
||||
- ✅ Authentication state managed correctly
|
||||
|
||||
### Integration Tests
|
||||
- ✅ New stores integrate properly with existing components
|
||||
- ✅ Auth0 integration works with enhanced user persistence
|
||||
- ✅ Unit system changes sync between old and new systems
|
||||
- ✅ No conflicts with existing Zustand store patterns
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Store Creation
|
||||
1. Create new navigation and user stores
|
||||
2. Implement form state management hook
|
||||
3. Test stores in isolation
|
||||
|
||||
### Phase 2: Mobile App Integration
|
||||
1. Update App.tsx to use new navigation store
|
||||
2. Modify mobile screens to use form state hook
|
||||
3. Test mobile navigation and persistence
|
||||
|
||||
### Phase 3: System Integration
|
||||
1. Integrate with existing Auth0 provider
|
||||
2. Update unit system to use new user store
|
||||
3. Ensure backward compatibility
|
||||
|
||||
### Phase 4: Enhancement & Optimization
|
||||
1. Add advanced features like offline persistence
|
||||
2. Optimize performance and storage usage
|
||||
3. Add error handling and recovery mechanisms
|
||||
|
||||
## Success Criteria
|
||||
|
||||
Upon completion:
|
||||
|
||||
1. **Navigation Consistency**: Mobile navigation maintains context across all transitions
|
||||
2. **State Persistence**: All user data, preferences, and form states persist appropriately
|
||||
3. **Form Recovery**: Users can navigate away from forms and return without data loss
|
||||
4. **User Context**: User preferences and settings sync immediately across all screens
|
||||
5. **Back Navigation**: Mobile back button works correctly with navigation history
|
||||
6. **Integration**: New state management integrates seamlessly with existing systems
|
||||
|
||||
This enhanced state management system will provide a robust foundation for consistent mobile and desktop experiences while maintaining all existing functionality.
|
||||
Reference in New Issue
Block a user