import reducer from '@joplin/lib/reducer'; import { AppState, Route } from './types'; import appDefaultState, { DEFAULT_ROUTE } from './appDefaultState'; import fastDeepEqual = require('fast-deep-equal'); import Logger from '@joplin/utils/Logger'; import { TagsWithNoteCountEntity } from '@joplin/lib/services/database/types'; import getConflictFolderId from '@joplin/lib/models/utils/getConflictFolderId'; const logger = Logger.create('appReducer'); const navHistory: Route[] = []; function historyCanGoBackTo(route: Route) { if (route.routeName === 'Folder') return false; // This is an intermediate screen that acts more like a modal -- it should be skipped in the // navigation history. if (route.routeName === 'DocumentScanner') return false; // There's no point going back to these screens in general and, at least in OneDrive case, // it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...) if (route.routeName === 'OneDriveLogin') return false; if (route.routeName === 'DropboxLogin') return false; return true; } function removeAdjacentNoteDuplicates(items: Route[]) { return items.filter((item, idx) => (idx >= 1) ? !(item.routeName === 'Note' && items[idx - 1].routeName === 'Note' && item.noteId && items[idx - 1].noteId === item.noteId) : true); } function removeAdjacentFolderDuplicates(items: Route[]) { return items.filter((item, idx) => (idx >= 1) ? !(item.routeName === 'Notes' && items[idx - 1].routeName === 'Notes' && item.folderId && items[idx - 1].folderId === item.folderId) : true); } function removeAdjacentTagDuplicates(items: Route[]) { return items.filter((item, idx) => (idx >= 1) ? !(item.routeName === 'Notes' && items[idx - 1].routeName === 'Notes' && item.tagId && items[idx - 1].tagId === item.tagId) : true); } function removeAdjacentTagScreenDuplicates(items: Route[]) { return items.filter((item, idx) => (idx >= 1) ? !(item.routeName === 'Tags' && items[idx - 1].routeName === 'Tags') : true); } function removeLatestFolderIfSelected(items: Route[], route: Route) { if (items.length && route.routeName === 'Notes' && items[items.length - 1].routeName === 'Notes' && route.folderId && items[items.length - 1].folderId === route.folderId) { items.splice(items.length - 1, 1); } } function removeLatestTagScreenIfSelected(items: Route[], route: Route) { if (items.length && route.routeName === 'Tags' && items[items.length - 1].routeName === 'Tags') { items.splice(items.length - 1, 1); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Routes/actions are heterogeneous redux NAV payloads (NAV_GO/NAV_BACK with optional folderId/tagId/noteId/isDeleted/etc.); typing them requires defining a discriminated action union across the mobile codebase const appReducer = (state = appDefaultState, action: any) => { let newState = state; let historyGoingBack = false; try { switch (action.type) { case 'NAV_BACK': case 'NAV_GO': if (action.type === 'NAV_BACK') { if (!navHistory.length) break; const newAction = navHistory.pop(); action = newAction ? newAction : navHistory.pop(); historyGoingBack = true; } { const currentRoute = state.route; if (!historyGoingBack && historyCanGoBackTo(currentRoute)) { const previousRoute = navHistory.length && navHistory[navHistory.length - 1]; const isDifferentRoute = !previousRoute || !fastDeepEqual(navHistory[navHistory.length - 1], currentRoute); // Avoid multiple consecutive duplicate screens in the navigation history -- these can make // pressing "back" seem to have no effect. if (currentRoute.isDeleted) { // Do not add the item to the history, and remove the last item in the history if that is now the selected item removeLatestFolderIfSelected(navHistory, action); // Push DEFAULT_ROUTE so there's always a valid back target after deletion if (!navHistory.length) { navHistory.push(DEFAULT_ROUTE); } } else if (isDifferentRoute) { navHistory.push(currentRoute); } } if (action.clearHistory) { navHistory.splice(0, navHistory.length); } newState = { ...state }; newState.selectedNoteHash = ''; if (currentRoute.routeName === 'Search' && action.routeName === 'Notes') { // Force a reload of the note list newState.notesSource = ''; } if (action.routeName === 'Search') { newState.notesParentType = 'Search'; } if ('noteId' in action) { newState.selectedNoteIds = action.noteId ? [action.noteId] : []; } if ('folderId' in action) { newState.selectedFolderId = action.folderId; newState.selectedFolderIds = [action.folderId]; newState.notesParentType = 'Folder'; } if ('tagId' in action) { newState.selectedTagId = action.tagId; newState.selectedTagIds = [action.tagId]; newState.notesParentType = 'Tag'; } if ('smartFilterId' in action) { newState.smartFilterId = action.smartFilterId; newState.selectedSmartFilterId = action.smartFilterId; newState.notesParentType = 'SmartFilter'; } if ('itemType' in action) { newState.selectedItemType = action.itemType; } if ('noteHash' in action) { newState.selectedNoteHash = action.noteHash; } if ('sharedData' in action) { newState.sharedData = action.sharedData; } else { newState.sharedData = null; } newState.route = action; newState.historyCanGoBack = !!navHistory.length; logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType); } break; case 'TAG_UPDATE_ALL': { // A tag can be removed from the UI via deleting a tag, deleting a note, deleting a notebook, or manually removing all note associations for // a tag. Cleaning history here covers all of these scenarios const availableTagIds = new Set( action.items.map((o: TagsWithNoteCountEntity) => o.id), ); let newNavHistoryForTags = navHistory.filter(route => route.routeName !== 'Notes' || !route.tagId || availableTagIds.has(route.tagId)); newNavHistoryForTags = removeAdjacentTagDuplicates(newNavHistoryForTags); newNavHistoryForTags = removeAdjacentTagScreenDuplicates(newNavHistoryForTags); removeLatestTagScreenIfSelected(newNavHistoryForTags, state.route); navHistory.splice(0, navHistory.length, ...newNavHistoryForTags); } break; case 'FOLDER_DELETE': { let newNavHistoryForFolder = navHistory.filter(route => !(route.routeName === 'Notes' && route.folderId === action.id)); newNavHistoryForFolder = removeAdjacentFolderDuplicates(newNavHistoryForFolder); removeLatestFolderIfSelected(newNavHistoryForFolder, state.route); navHistory.splice(0, navHistory.length, ...newNavHistoryForFolder); // Prevent the deleted folder from being added to the navigation history again when navigating forward, where the selected folder was deleted newState = { ...state, route: { ...state.route, isDeleted: true, }, }; } break; case 'FOLDER_UPDATE_ALL': { const conflictFolderId = getConflictFolderId(); const conflictFolderRemoved = state.folders.some(folder => folder.id === conflictFolderId) && !action.items.some((folder: { id: string }) => folder.id === conflictFolderId); if (conflictFolderRemoved) { let newNavHistory = navHistory.filter(route => !(route.routeName === 'Notes' && route.folderId === conflictFolderId)); newNavHistory = removeAdjacentFolderDuplicates(newNavHistory); removeLatestFolderIfSelected(newNavHistory, state.route); navHistory.splice(0, navHistory.length, ...newNavHistory); if (state.route.routeName === 'Notes' && state.route.folderId === conflictFolderId) { newState = { ...state, route: { ...state.route, isDeleted: true, }, }; } } } break; case 'NOTE_DELETE': { let newNavHistory = navHistory.filter(route => !(route.routeName === 'Note' && route.noteId === action.id)); newNavHistory = removeAdjacentNoteDuplicates(newNavHistory); // Fix the case where after deletion, the currently selected folder is also the latest in history // Notes are not relevant for this scenario, because both note and folder deletion redirects to a folder rather than a note on mobile removeLatestFolderIfSelected(newNavHistory, state.route); navHistory.splice(0, navHistory.length, ...newNavHistory); } break; case 'SIDE_MENU_TOGGLE': newState = { ...state }; newState.showSideMenu = !newState.showSideMenu; break; case 'SIDE_MENU_OPEN': newState = { ...state }; newState.showSideMenu = true; break; case 'SIDE_MENU_CLOSE': newState = { ...state }; newState.showSideMenu = false; break; case 'SET_PLUGIN_PANELS_DIALOG_VISIBLE': newState = { ...state }; newState.showPanelsDialog = action.visible; break; case 'NOTE_SELECTION_TOGGLE': { newState = { ...state }; const noteId = action.id; const newSelectedNoteIds = state.selectedNoteIds.slice(); const existingIndex = state.selectedNoteIds.indexOf(noteId); if (existingIndex >= 0) { newSelectedNoteIds.splice(existingIndex, 1); } else { newSelectedNoteIds.push(noteId); } newState.selectedNoteIds = newSelectedNoteIds; newState.noteSelectionEnabled = !!newSelectedNoteIds.length; } break; case 'NOTE_SELECTION_START': if (!state.noteSelectionEnabled) { newState = { ...state }; newState.noteSelectionEnabled = true; newState.selectedNoteIds = [action.id]; } break; case 'NOTE_SELECTION_END': newState = { ...state }; newState.noteSelectionEnabled = false; newState.selectedNoteIds = []; break; case 'NOTE_SIDE_MENU_OPTIONS_SET': newState = { ...state }; newState.noteSideMenuOptions = action.options; break; case 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED': newState = { ...state }; newState.disableSideMenuGestures = action.disableSideMenuGestures; break; case 'MOBILE_DATA_WARNING_UPDATE': newState = { ...state }; newState.isOnMobileData = action.isOnMobileData; break; case 'KEYBOARD_VISIBLE_CHANGE': newState = { ...state, keyboardVisible: action.visible }; break; case 'NOTE_EDITOR_VISIBLE_CHANGE': newState = { ...state, noteEditorVisible: action.visible }; break; case 'SYNC_WIZARD_VISIBLE_CHANGE': newState = { ...state, syncWizardVisible: action.visible }; break; case 'NOTE_VISIBLE_PANES_SET': newState = { ...state, noteVisiblePanes: Array.isArray(action.panes) && action.panes.length ? action.panes : ['viewer'], }; break; } } catch (error) { error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`; throw error; } return reducer(newState, action) as AppState; }; export default appReducer;