1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-10 22:11:50 +02:00

Mobile: Add note revision viewer (#12305)

This commit is contained in:
Henry Heino
2025-05-27 09:22:52 -07:00
committed by GitHub
parent 47e4f36f97
commit 293eac9c04
13 changed files with 366 additions and 30 deletions

View File

@@ -806,6 +806,8 @@ packages/app-mobile/components/screens/Note/commands/insertDateTime.js
packages/app-mobile/components/screens/Note/commands/setTags.js
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.js
packages/app-mobile/components/screens/Note/types.js
packages/app-mobile/components/screens/NoteRevisionViewer.test.js
packages/app-mobile/components/screens/NoteRevisionViewer.js
packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
packages/app-mobile/components/screens/Notes/NewNoteButton.js
@@ -1075,6 +1077,7 @@ packages/lib/commands/toggleEditorPlugin.js
packages/lib/components/EncryptionConfigScreen/utils.test.js
packages/lib/components/EncryptionConfigScreen/utils.js
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
packages/lib/components/shared/config/config-shared.js
packages/lib/components/shared/config/plugins/types.js
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js

3
.gitignore vendored
View File

@@ -780,6 +780,8 @@ packages/app-mobile/components/screens/Note/commands/insertDateTime.js
packages/app-mobile/components/screens/Note/commands/setTags.js
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.js
packages/app-mobile/components/screens/Note/types.js
packages/app-mobile/components/screens/NoteRevisionViewer.test.js
packages/app-mobile/components/screens/NoteRevisionViewer.js
packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
packages/app-mobile/components/screens/Notes/NewNoteButton.js
@@ -1049,6 +1051,7 @@ packages/lib/commands/toggleEditorPlugin.js
packages/lib/components/EncryptionConfigScreen/utils.test.js
packages/lib/components/EncryptionConfigScreen/utils.js
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
packages/lib/components/shared/config/config-shared.js
packages/lib/components/shared/config/plugins/types.js
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js

View File

@@ -16,6 +16,7 @@ const urlUtils = require('@joplin/lib/urlUtils');
const ReactTooltip = require('react-tooltip');
const { connect } = require('react-redux');
import shared from '@joplin/lib/components/shared/note-screen-shared';
import getHelpMessage from '@joplin/lib/components/shared/NoteRevisionViewer/getHelpMessage';
import shim, { MessageBoxType } from '@joplin/lib/shim';
import { RefObject, useCallback, useRef, useState } from 'react';
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
@@ -163,7 +164,7 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
}
const restoreButtonTitle = _('Restore');
const helpMessage = _('Click "%s" to restore the note. It will be copied in the notebook named "%s". The current version of the note will not be replaced or modified.', restoreButtonTitle, RevisionService.instance().restoreFolderTitle());
const helpMessage = getHelpMessage(restoreButtonTitle);
const titleInput = (
<div className='revision-viewer-title'>

View File

@@ -23,6 +23,7 @@ interface DropdownProps {
headerStyle?: TextStyle;
itemStyle?: TextStyle;
disabled?: boolean;
defaultHeaderLabel?: string; // Defaults to "..."
accessibilityHint?: string;
labelTransform?: 'trim';
@@ -149,7 +150,7 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
const itemStyle = { ...(this.props.itemStyle ? this.props.itemStyle : {}) };
let headerLabel = '...';
let headerLabel = this.props.defaultHeaderLabel ?? '...';
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.value === this.props.selectedValue) {

View File

@@ -9,7 +9,7 @@ import NoteBodyViewer from './NoteBodyViewer';
import Setting from '@joplin/lib/models/Setting';
import { resourceFetcher, setupDatabaseAndSynchronizer, supportDir, switchClient, synchronizerStart } from '@joplin/lib/testing/test-utils';
import { MarkupLanguage } from '@joplin/renderer';
import { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage';
import { OnMarkForDownloadCallback } from './hooks/useOnMessage';
import Resource from '@joplin/lib/models/Resource';
import shim from '@joplin/lib/shim';
import Note from '@joplin/lib/models/Note';
@@ -22,7 +22,6 @@ interface WrapperProps {
noteBody: string;
highlightedKeywords?: string[];
noteResources?: Record<string, ResourceInfo>;
onJoplinLinkClick?: HandleMessageCallback;
onScroll?: (percent: number)=> void;
onMarkForDownload?: OnMarkForDownloadCallback;
}
@@ -36,16 +35,13 @@ const WrappedNoteViewer: React.FC<WrapperProps> = (
noteBody,
highlightedKeywords = emptyArray,
noteResources = emptyObject,
onJoplinLinkClick = noOpFunction,
onScroll = noOpFunction,
onMarkForDownload,
}: WrapperProps,
) => {
return <TestProviderStack store={testStore}>
<NoteBodyViewer
themeId={Setting.THEME_LIGHT}
style={emptyObject}
fontSize={12}
noteBody={noteBody}
noteMarkupLanguage={MarkupLanguage.Markdown}
highlightedKeywords={highlightedKeywords}
@@ -53,10 +49,8 @@ const WrappedNoteViewer: React.FC<WrapperProps> = (
paddingBottom={0}
initialScroll={0}
noteHash={''}
onJoplinLinkClick={onJoplinLinkClick}
onMarkForDownload={onMarkForDownload}
onScroll={onScroll}
pluginStates={emptyObject}
/>
</TestProviderStack>;
};

View File

@@ -15,6 +15,10 @@ import uuid from '@joplin/lib/uuid';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import useContentScripts from './hooks/useContentScripts';
import { MarkupLanguage } from '@joplin/renderer';
import shim from '@joplin/lib/shim';
import CommandService from '@joplin/lib/services/CommandService';
import { AppState } from '../../utils/types';
import { connect } from 'react-redux';
interface Props {
themeId: number;
@@ -27,7 +31,6 @@ interface Props {
paddingBottom: number;
initialScroll: number|null;
noteHash: string;
onJoplinLinkClick: HandleMessageCallback;
onCheckboxChange?: HandleMessageCallback;
onRequestEditResource?: HandleMessageCallback;
onMarkForDownload?: OnMarkForDownloadCallback;
@@ -36,7 +39,15 @@ interface Props {
pluginStates: PluginStates;
}
export default function NoteBodyViewer(props: Props) {
const onJoplinLinkClick = async (message: string) => {
try {
await CommandService.instance().execute('openItem', message);
} catch (error) {
await shim.showErrorDialog(error.message);
}
};
function NoteBodyViewer(props: Props) {
const webviewRef = useRef<WebViewControl>(null);
const onScroll = useCallback(async (scrollTop: number) => {
@@ -45,14 +56,14 @@ export default function NoteBodyViewer(props: Props) {
const onResourceLongPress = useOnResourceLongPress(
{
onJoplinLinkClick: props.onJoplinLinkClick,
onJoplinLinkClick,
onRequestEditResource: props.onRequestEditResource,
},
);
const onPostMessage = useOnMessage(props.noteBody, {
onMarkForDownload: props.onMarkForDownload,
onJoplinLinkClick: props.onJoplinLinkClick,
onJoplinLinkClick,
onRequestEditResource: props.onRequestEditResource,
onCheckboxChange: props.onCheckboxChange,
onResourceLongPress,
@@ -118,3 +129,9 @@ export default function NoteBodyViewer(props: Props) {
</View>
);
}
export default connect((state: AppState) => ({
themeId: state.settings.theme,
fontSize: state.settings['style.viewer.fontSize'],
pluginStates: state.pluginService.plugins,
}))(NoteBodyViewer);

View File

@@ -159,8 +159,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private noteTagDialog_closeRequested: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private onJoplinLinkClick_: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private refreshResource: (resource: any, noteBody?: string)=> Promise<void>;
private selection: SelectionRange;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -287,14 +285,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
this.setState({ noteTagDialogShown: false });
};
this.onJoplinLinkClick_ = async (msg: string) => {
try {
await CommandService.instance().execute('openItem', msg);
} catch (error) {
await this.props.dialogs.error(error.message);
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
this.refreshResource = async (resource: any, noteBody: string = null) => {
if (noteBody === null && this.state.note && this.state.note.body) noteBody = this.state.note.body;
@@ -1091,6 +1081,15 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
void this.showOnMap_onPress();
},
});
output.push({
title: _('Previous versions'),
onPress: () => {
this.props.dispatch({ type: 'SIDE_MENU_CLOSE' });
void NavService.go('NoteRevisionViewer', {
noteId: this.props.noteId,
});
},
});
if (note.source_url) {
output.push({
title: _('Go to source URL'),
@@ -1519,7 +1518,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
bodyComponent =
!note || !note.body.trim() ? null : (
<NoteBodyViewer
onJoplinLinkClick={this.onJoplinLinkClick_}
style={this.styles().noteBodyViewer}
// Extra bottom padding to make it possible to scroll past the
// action button (so that it doesn't overlap the text)
@@ -1528,15 +1526,12 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
noteMarkupLanguage={note.markup_language}
noteResources={this.state.noteResources}
highlightedKeywords={keywords}
themeId={this.props.themeId}
fontSize={this.props.viewerFontSize}
noteHash={this.props.noteHash}
onCheckboxChange={this.onBodyViewerCheckboxChange}
onMarkForDownload={this.onMarkForDownload}
onRequestEditResource={this.onEditResource}
onScroll={this.onBodyViewerScroll}
initialScroll={this.lastBodyScroll}
pluginStates={this.props.plugins}
/>
);
} else {

View File

@@ -0,0 +1,105 @@
import * as React from 'react';
import { Store } from 'redux';
import { AppState } from '../../utils/types';
import TestProviderStack from '../testing/TestProviderStack';
import NoteRevisionViewer from './NoteRevisionViewer';
import { setupDatabaseAndSynchronizer, switchClient, revisionService, waitFor } from '@joplin/lib/testing/test-utils';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import '@testing-library/jest-native/extend-expect';
import Note from '@joplin/lib/models/Note';
import { useMemo } from 'react';
import Revision from '@joplin/lib/models/Revision';
import { ModelType } from '@joplin/lib/BaseModel';
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
interface WrapperProps {
noteId: string;
}
let store: Store<AppState>;
const WrappedRevisionViewerScreen: React.FC<WrapperProps> = ({ noteId }) => {
const navigationState = useMemo(() => ({
state: { noteId },
}), [noteId]);
return <TestProviderStack store={store}>
<NoteRevisionViewer
navigation={navigationState}
/>
</TestProviderStack>;
};
const createNoteWithTestRevisions = async (count: number) => {
const note = await Note.save({ title: 'Note', body: 'Test', parent_id: '' });
const noteId = note.id;
for (let i = 0; i < count; i++) {
jest.advanceTimersByTime(1000 * 60 * 10);
await Note.save({
id: noteId,
title: `Note - Updated (x${i + 1})`,
body: `Update ${i + 1}`,
});
await revisionService().collectRevisions();
}
// Verify that the revisions were created successfully
expect(await Revision.allByType(ModelType.Note, noteId)).toHaveLength(count);
return note;
};
const getRevisionViewerDom = async () => {
return await getWebViewDomById('NoteBodyViewer');
};
const getRevisionViewerText = async () => {
// Use #rendered-md and not body. With jsdom, 'body' has
// CSS in its .textContent.
const mainContent = (await getRevisionViewerDom()).querySelector('#rendered-md');
return mainContent.textContent.trim();
};
describe('screens/NoteRevisionViewer', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
store = createMockReduxStore();
setupGlobalStore(store);
jest.useFakeTimers({ advanceTimers: true });
});
afterEach(() => {
screen.unmount();
});
test('should render "No revision selected" when no revisions are selected', async () => {
const note = await createNoteWithTestRevisions(3);
const { unmount } = render(<WrappedRevisionViewerScreen noteId={note.id}/>);
expect(await getRevisionViewerText()).toBe('No revision selected');
unmount();
});
test('selecting a revision should render its content', async () => {
const note = await createNoteWithTestRevisions(3);
const { unmount } = render(<WrappedRevisionViewerScreen noteId={note.id}/>);
const dropdown = screen.getByRole('button', { name: 'Select a revision...' });
fireEvent.press(dropdown);
// Select the second revision
await act(() => waitFor(async () => {
const firstRevision = screen.getAllByRole('menuitem')[1];
fireEvent.press(firstRevision);
}));
await act(() => waitFor(async () => {
expect(await getRevisionViewerText()).toBe('Update 2');
}));
unmount();
});
});

View File

@@ -0,0 +1,196 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { View, StyleSheet } from 'react-native';
import { AppState } from '../../utils/types';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import Revision from '@joplin/lib/models/Revision';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import { IconButton, Text } from 'react-native-paper';
import Dropdown from '../Dropdown';
import ScreenHeader from '../ScreenHeader';
import { formatMsToLocal } from '@joplin/utils/time';
import { useCallback, useContext, useMemo, useState } from 'react';
import { PrimaryButton } from '../buttons';
import { _ } from '@joplin/lib/locale';
import { NoteEntity, RevisionEntity } from '@joplin/lib/services/database/types';
import RevisionService from '@joplin/lib/services/RevisionService';
import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer';
import { MarkupLanguage } from '@joplin/renderer';
import attachedResources, { AttachedResources } from '@joplin/lib/utils/attachedResources';
import shim, { MessageBoxType } from '@joplin/lib/shim';
import { themeStyle } from '../global-style';
import getHelpMessage from '@joplin/lib/components/shared/NoteRevisionViewer/getHelpMessage';
import { DialogContext } from '../DialogManager';
interface Props {
themeId: number;
selectedNoteId: string;
// Properties passed by the navigation logic
navigation?: {
state?: {
noteId: string;
};
};
}
const useRevisions = (noteId: string) => {
const [revisions, setRevisions] = useState<RevisionEntity[]>([]);
useAsyncEffect(async (event) => {
if (!noteId) {
setRevisions([]);
return;
}
const revisions = await Revision.allByType(ModelType.Note, noteId);
if (event.cancelled) return;
setRevisions(revisions);
}, [noteId]);
return revisions;
};
const useRevisionNote = (revisions: RevisionEntity[], revisionId: string) => {
const [note, setNote] = useState<NoteEntity|null>(null);
const [resources, setResources] = useState<AttachedResources>({});
useAsyncEffect(async event => {
const revisionIndex = BaseModel.modelIndexById(revisions, revisionId);
if (revisionIndex === -1) {
setNote(null);
return;
}
const note = await RevisionService.instance().revisionNote(revisions, revisionIndex);
if (event.cancelled) return;
setNote(note);
const resources = await attachedResources(note?.body ?? '');
if (event.cancelled) return;
setResources(resources);
}, [revisions, revisionId]);
return { note, resources };
};
const useStyles = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
noteViewer: {
flex: 1,
},
controls: {
padding: theme.margin,
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: 3,
},
dropdownListStyle: {
backgroundColor: theme.backgroundColor,
},
dropdownItemStyle: {
color: theme.color,
fontSize: theme.fontSize,
},
dropdownHeaderStyle: {
color: theme.color,
fontSize: theme.fontSize,
},
root: {
...theme.rootStyle,
},
});
}, [themeId]);
};
const emptyStringList: string[] = [];
const NoteRevisionViewer: React.FC<Props> = props => {
const noteId = props.navigation?.state?.noteId ?? props.selectedNoteId;
const revisions = useRevisions(noteId);
const [currentRevisionId, setCurrentRevisionId] = useState<string>('');
const { note, resources } = useRevisionNote(revisions, currentRevisionId);
const [initialScroll, setInitialScroll] = useState(0);
const options = useMemo(() => {
const result = [];
for (const revision of revisions) {
const stats = Revision.revisionPatchStatsText(revision);
result.push({
label: `${formatMsToLocal(revision.item_updated_time)} (${stats})`,
value: revision.id,
});
}
return result;
}, [revisions]);
const onOptionSelected = useCallback((value: string) => {
setCurrentRevisionId(value);
}, []);
const [restoring, setRestoring] = useState(false);
const onRestore = useCallback(async () => {
if (!note) return;
setRestoring(true);
try {
await RevisionService.instance().importRevisionNote(note);
await shim.showMessageBox(RevisionService.instance().restoreSuccessMessage(note), { type: MessageBoxType.Info });
} finally {
setRestoring(false);
}
}, [note]);
const restoreButtonTitle = _('Restore');
const helpMessageText = getHelpMessage(restoreButtonTitle);
const dialogs = useContext(DialogContext);
const onHelpPress = useCallback(() => {
void dialogs.info(helpMessageText);
}, [helpMessageText, dialogs]);
const styles = useStyles(props.themeId);
const dropdownLabelText = _('Revision:');
return <View style={styles.root}>
<ScreenHeader title={_('Note history')} />
<View style={styles.controls}>
<Text variant='labelLarge'>{dropdownLabelText}</Text>
<Dropdown
items={options}
onValueChange={onOptionSelected}
selectedValue={currentRevisionId}
itemListStyle={styles.dropdownListStyle}
headerStyle={styles.dropdownHeaderStyle}
itemStyle={styles.dropdownItemStyle}
accessibilityHint={dropdownLabelText}
defaultHeaderLabel={_('Select a revision...')}
/>
<PrimaryButton
onPress={onRestore}
disabled={restoring || !note}
>{restoreButtonTitle}</PrimaryButton>
<IconButton
icon='help-circle-outline'
accessibilityLabel={_('Help')}
onPress={onHelpPress}
/>
</View>
<NoteBodyViewer
style={styles.noteViewer}
noteBody={note?.body ?? _('No revision selected')}
noteMarkupLanguage={MarkupLanguage.Markdown}
noteResources={resources}
highlightedKeywords={emptyStringList}
paddingBottom={0}
initialScroll={initialScroll}
onScroll={setInitialScroll}
noteHash={''}
/>
</View>;
};
export default connect((state: AppState) => ({
themeId: state.settings.theme,
selectedNoteId: state.selectedNoteIds[0] ?? '',
}))(NoteRevisionViewer);

View File

@@ -141,6 +141,7 @@ import { AppState } from './utils/types';
import { getDisplayParentId } from '@joplin/lib/services/trash';
import PluginNotification from './components/plugins/PluginNotification';
import FocusControl from './components/accessibility/FocusControl/FocusControl';
import NoteRevisionViewer from './components/screens/NoteRevisionViewer';
const logger = Logger.create('root');
@@ -1306,6 +1307,7 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
ShareManager: { screen: ShareManager },
ProfileSwitcher: { screen: ProfileSwitcher },
ProfileEditor: { screen: ProfileEditor },
NoteRevisionViewer: { screen: NoteRevisionViewer },
Log: { screen: LogScreen },
Status: { screen: StatusScreen },
Search: { screen: SearchScreen },

View File

@@ -0,0 +1,8 @@
import { _ } from '../../../locale';
import RevisionService from '../../../services/RevisionService';
const getHelpMessage = (restoreButtonTitle: string) => {
return _('Click "%s" to restore the note. It will be copied in the notebook named "%s". The current version of the note will not be replaced or modified.', restoreButtonTitle, RevisionService.instance().restoreFolderTitle());
};
export default getHelpMessage;

View File

@@ -1,6 +1,8 @@
import BaseModel from '../BaseModel';
import Note from '../models/Note';
import Resource from '../models/Resource';
import ResourceLocalState from '../models/ResourceLocalState';
import { ResourceEntity } from '../services/database/types';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
let resourceCache_: any = {};
@@ -9,8 +11,15 @@ export function clearResourceCache() {
resourceCache_ = {};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export default async function attachedResources(noteBody: string): Promise<any> {
interface AttachedResource {
item: ResourceEntity;
localState: ResourceLocalState;
}
export interface AttachedResources {
[id: string]: AttachedResource;
}
export default async function attachedResources(noteBody: string): Promise<AttachedResources> {
if (!noteBody) return {};
const resourceIds = await Note.linkedItemIdsByType(BaseModel.TYPE_RESOURCE, noteBody);

View File

@@ -3,7 +3,9 @@
// added here, and should be based on dayjs (not moment)
// -----------------------------------------------------------------------------------------------
import * as dayjs from 'dayjs';
import type * as dayjsImport from 'dayjs';
// A require() is needed here for this to work in React Native.
const dayjs: typeof dayjsImport = require('dayjs');
// Separating this into a type import and a require seems to be necessary to support mobile:
// - import = require syntax doesn't work when bundling