Refactor navigation logic from Home.tsx to NavigationContext (#935)

This commit is contained in:
Leendert de Borst
2025-06-18 12:37:02 +02:00
committed by Leendert de Borst
parent 3ca4c0a78d
commit ee6b40dd3d
4 changed files with 163 additions and 87 deletions

View File

@@ -8,6 +8,7 @@ import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { NavigationProvider } from '@/entrypoints/popup/context/NavigationContext';
import AuthSettings from '@/entrypoints/popup/pages/AuthSettings';
import CredentialAddEdit from '@/entrypoints/popup/pages/CredentialAddEdit';
import CredentialDetails from '@/entrypoints/popup/pages/CredentialDetails';
@@ -15,13 +16,15 @@ import CredentialsList from '@/entrypoints/popup/pages/CredentialsList';
import EmailDetails from '@/entrypoints/popup/pages/EmailDetails';
import EmailsList from '@/entrypoints/popup/pages/EmailsList';
import Home from '@/entrypoints/popup/pages/Home';
import Login from '@/entrypoints/popup/pages/Login';
import Logout from '@/entrypoints/popup/pages/Logout';
import Settings from '@/entrypoints/popup/pages/Settings';
import Unlock from '@/entrypoints/popup/pages/Unlock';
import UnlockSuccess from '@/entrypoints/popup/pages/UnlockSuccess';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import '@/entrypoints/popup/style.css';
import { NavigationProvider } from './context/NavigationContext';
/**
* Route configuration.
@@ -46,6 +49,9 @@ const App: React.FC = () => {
// Add these route configurations
const routes: RouteConfig[] = [
{ path: '/', element: <Home />, showBackButton: false },
{ path: '/login', element: <Login />, showBackButton: false },
{ path: '/unlock', element: <Unlock />, showBackButton: false },
{ path: '/unlock-success', element: <UnlockSuccess onClose={() => window.location.search = ''} />, showBackButton: false },
{ path: '/auth-settings', element: <AuthSettings />, showBackButton: true, title: 'Settings' },
{ path: '/credentials', element: <CredentialsList />, showBackButton: false },
{ path: '/credentials/add', element: <CredentialAddEdit />, showBackButton: true, title: 'Add credential' },

View File

@@ -2,56 +2,165 @@ import React, { createContext, useContext, useEffect, useState, useMemo, useCall
import { useLocation, useNavigate } from 'react-router-dom';
import { sendMessage } from 'webext-bridge/popup';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { storage } from '#imports';
const LAST_VISITED_PAGE_KEY = 'session:lastVisitedPage';
const LAST_VISITED_TIME_KEY = 'session:lastVisitedTime';
const NAVIGATION_HISTORY_KEY = 'session:navigationHistory';
const PAGE_MEMORY_DURATION = 120 * 1000; // 2 minutes in milliseconds
type NavigationHistoryEntry = {
pathname: string;
search: string;
hash: string;
};
type NavigationContextType = {
storeCurrentPage: () => Promise<void>;
restoreLastPage: () => Promise<void>;
isFullyInitialized: boolean;
requiresAuth: boolean;
};
const NavigationContext = createContext<NavigationContextType | undefined>(undefined);
/**
* Navigation provider component that handles storing and restoring the last visited page.
* Navigation provider component that handles storing and restoring the last visited page,
* as well as managing initialization and auth state redirects.
*/
export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const location = useLocation();
const navigate = useNavigate();
const [isInitialized, setIsInitialized] = useState(false);
const [isInlineUnlockMode, setIsInlineUnlockMode] = useState(false);
const { setIsInitialLoading } = useLoading();
// Auth and DB state
const { isInitialized: authInitialized, isLoggedIn } = useAuth();
const { dbInitialized, dbAvailable } = useDb();
// Derived state
const isFullyInitialized = authInitialized && dbInitialized;
const requiresAuth = isFullyInitialized && (!isLoggedIn || !dbAvailable || isInlineUnlockMode);
/**
* Store the current page path and timestamp in storage.
* Store the current page path, timestamp, and navigation history in storage.
*/
const storeCurrentPage = useCallback(async (): Promise<void> => {
await storage.setItem(LAST_VISITED_PAGE_KEY, location.pathname);
await storage.setItem(LAST_VISITED_TIME_KEY, Date.now());
}, [location.pathname]);
// Pages that are not allowed to be stored as these are auth conditional pages.
const notAllowedPaths = ['/', '/login', '/unlock', '/unlock-success', '/auth-settings'];
// Only store the page if we're fully initialized and don't need auth
if (isFullyInitialized && !requiresAuth && !notAllowedPaths.includes(location.pathname)) {
// Get the current history entries from the session history
const historyEntries: NavigationHistoryEntry[] = [];
if (window.history.state?.usr?.history) {
historyEntries.push(...window.history.state.usr.history);
}
// Add current location if not already in history
const currentEntry = {
pathname: location.pathname,
search: location.search,
hash: location.hash,
};
if (!historyEntries.some(entry => entry.pathname === currentEntry.pathname)) {
historyEntries.push(currentEntry);
}
await Promise.all([
storage.setItem(LAST_VISITED_PAGE_KEY, location.pathname),
storage.setItem(LAST_VISITED_TIME_KEY, Date.now()),
storage.setItem(NAVIGATION_HISTORY_KEY, historyEntries),
]);
}
}, [location, isFullyInitialized, requiresAuth]);
/**
* Restore the last visited page if it was visited within the memory duration.
* Restore the last visited page and navigation history if it was visited within the memory duration.
*/
const restoreLastPage = useCallback(async (): Promise<void> => {
const lastPage = await storage.getItem(LAST_VISITED_PAGE_KEY) as string;
const lastVisitTime = await storage.getItem(LAST_VISITED_TIME_KEY) as number;
// Only restore if we're fully initialized and don't need auth
if (!isFullyInitialized || requiresAuth) {
return;
}
const [lastPage, lastVisitTime, savedHistory] = await Promise.all([
storage.getItem(LAST_VISITED_PAGE_KEY) as Promise<string>,
storage.getItem(LAST_VISITED_TIME_KEY) as Promise<number>,
storage.getItem(NAVIGATION_HISTORY_KEY) as Promise<NavigationHistoryEntry[]>,
]);
if (lastPage && lastVisitTime) {
const timeSinceLastVisit = Date.now() - lastVisitTime;
if (timeSinceLastVisit <= PAGE_MEMORY_DURATION) {
navigate(lastPage);
} else {
// Duration has expired, clear the last visited page and time.
await storage.removeItem(LAST_VISITED_PAGE_KEY);
await storage.removeItem(LAST_VISITED_TIME_KEY);
// Restore the navigation history
if (savedHistory?.length) {
// First navigate to credentials page as the base
navigate('/credentials', { replace: true });
// Clear persisted form values if they exist.
await sendMessage('CLEAR_PERSISTED_FORM_VALUES', null, 'background');
// Then restore the history stack
for (const entry of savedHistory) {
navigate(entry.pathname + entry.search + entry.hash);
}
return;
}
// Fallback to simple navigation if no history
navigate('/credentials', { replace: true });
navigate(lastPage, { replace: true });
return;
}
}
}, [navigate]);
// Duration has expired, clear all stored navigation data
await Promise.all([
storage.removeItem(LAST_VISITED_PAGE_KEY),
storage.removeItem(LAST_VISITED_TIME_KEY),
storage.removeItem(NAVIGATION_HISTORY_KEY),
sendMessage('CLEAR_PERSISTED_FORM_VALUES', null, 'background'),
]);
// Navigate to the credentials page as default entry page.
navigate('/credentials', { replace: true });
}, [navigate, isFullyInitialized, requiresAuth]);
// Handle initialization and auth state changes
useEffect(() => {
// Check for inline unlock mode
const urlParams = new URLSearchParams(window.location.search);
const inlineUnlock = urlParams.get('mode') === 'inline_unlock';
setIsInlineUnlockMode(inlineUnlock);
if (isFullyInitialized) {
setIsInitialLoading(false);
if (requiresAuth) {
const allowedPaths = ['/login', '/unlock', '/unlock-success', '/auth-settings'];
if (allowedPaths.includes(location.pathname)) {
// Do not override the navigation if the current path is in the allowed paths.
return;
}
// Determine which auth page to show
if (!isLoggedIn) {
navigate('/login', { replace: true });
} else if (!dbAvailable) {
navigate('/unlock', { replace: true });
} else if (inlineUnlock) {
navigate('/unlock-success', { replace: true });
}
} else if (!isInitialized) {
// First initialization, try to restore last page or go to credentials
restoreLastPage().then(() => {
setIsInitialized(true);
});
}
}
}, [isFullyInitialized, requiresAuth, isLoggedIn, dbAvailable, isInitialized, navigate, restoreLastPage, setIsInitialLoading, location.pathname]);
// Store the current page whenever it changes
useEffect(() => {
@@ -60,18 +169,12 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch
}
}, [location.pathname, isInitialized, storeCurrentPage]);
// Restore the last page on initial load
useEffect(() => {
if (!isInitialized) {
restoreLastPage();
setIsInitialized(true);
}
}, [isInitialized, restoreLastPage]);
const contextValue = useMemo(() => ({
storeCurrentPage,
restoreLastPage
}), [storeCurrentPage, restoreLastPage]);
restoreLastPage,
isFullyInitialized,
requiresAuth
}), [storeCurrentPage, restoreLastPage, isFullyInitialized, requiresAuth]);
return (
<NavigationContext.Provider value={contextValue}>

View File

@@ -102,9 +102,6 @@ const CredentialAddEdit: React.FC = () => {
}
});
// If we received an ID, we're in edit mode
const isEditMode = id !== undefined && id.length > 0;
/**
* Persists the current form values to storage
* @returns Promise that resolves when the form values are persisted
@@ -127,6 +124,26 @@ const CredentialAddEdit: React.FC = () => {
await sendMessage('PERSIST_FORM_VALUES', JSON.stringify(persistedData), 'background');
}, [watch, id, mode, localLoading]);
/**
* Watch for mode changes and persist form values
*/
useEffect(() => {
if (!localLoading) {
void persistFormValues();
}
}, [mode, persistFormValues, localLoading]);
// Watch for form changes and persist them
useEffect(() => {
const subscription = watch(() => {
void persistFormValues();
});
return (): void => subscription.unsubscribe();
}, [watch, persistFormValues]);
// If we received an ID, we're in edit mode
const isEditMode = id !== undefined && id.length > 0;
/**
* Loads persisted form values from storage. This is used to keep track of form changes
* and restore them when the page is reloaded. The browser extension popup will close
@@ -233,14 +250,6 @@ const CredentialAddEdit: React.FC = () => {
}
}, [dbContext.sqliteClient, id, navigate, setIsInitialLoading, setValue, loadPersistedValues]);
// Watch for form changes and persist them
useEffect(() => {
const subscription = watch(() => {
void persistFormValues();
});
return (): void => subscription.unsubscribe();
}, [watch, persistFormValues]);
/**
* Handle the delete button click.
*/

View File

@@ -1,62 +1,20 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import Login from '@/entrypoints/popup/pages/Login';
import Unlock from '@/entrypoints/popup/pages/Unlock';
import UnlockSuccess from '@/entrypoints/popup/pages/UnlockSuccess';
import { useNavigation } from '@/entrypoints/popup/context/NavigationContext';
/**
* Home page that shows the correct page based on the user's authentication state.
* Most of the navigation logic is now handled by NavigationContext.
*/
const Home: React.FC = () => {
const authContext = useAuth();
const dbContext = useDb();
const navigate = useNavigate();
const { setIsInitialLoading } = useLoading();
const [isInlineUnlockMode, setIsInlineUnlockMode] = useState(false);
const { isFullyInitialized } = useNavigation();
// Initialization state.
const isFullyInitialized = authContext.isInitialized && dbContext.dbInitialized;
const isAuthenticated = authContext.isLoggedIn;
const isDatabaseAvailable = dbContext.dbAvailable;
const requireLoginOrUnlock = isFullyInitialized && (!isAuthenticated || !isDatabaseAvailable || isInlineUnlockMode);
useEffect(() => {
// Detect if the user is coming from the unlock page with mode=inline_unlock.
const urlParams = new URLSearchParams(window.location.search);
const isInlineUnlockMode = urlParams.get('mode') === 'inline_unlock';
setIsInlineUnlockMode(isInlineUnlockMode);
// Redirect to credentials if fully initialized and doesn't need unlock.
if (isFullyInitialized && !requireLoginOrUnlock) {
navigate('/credentials', { replace: true });
}
}, [isFullyInitialized, requireLoginOrUnlock, isInlineUnlockMode, navigate]);
// Show loading state if not fully initialized or when about to redirect to credentials.
if (!isFullyInitialized || (isFullyInitialized && !requireLoginOrUnlock)) {
// Global loading spinner will be shown by the parent component.
if (!isFullyInitialized) {
return null;
}
setIsInitialLoading(false);
if (!isAuthenticated) {
return <Login />;
}
if (!isDatabaseAvailable) {
return <Unlock />;
}
if (isInlineUnlockMode) {
return <UnlockSuccess onClose={() => setIsInlineUnlockMode(false)} />;
}
return null;
return <Navigate to="/credentials" replace />;
};
export default Home;