一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
337 行
11 KiB
TypeScript
337 行
11 KiB
TypeScript
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;
|