一些检查失败了
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
371 行
12 KiB
TypeScript
371 行
12 KiB
TypeScript
import * as React from 'react';
|
|
import { connect } from 'react-redux';
|
|
import { View, StyleSheet, TextInput, ScrollView, Text as TextNative } 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, { MenuOption } 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';
|
|
import useDeleteHistoryClick from '@joplin/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick';
|
|
import { OnScrollCallback } from '../NoteBodyViewer/types';
|
|
import TextWrapCalculator from './Notes/TextWrapCalculator';
|
|
import { MenuOptionStyle } from '../BottomDrawerMenu';
|
|
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
|
import NoteLockService from '@joplin/lib/services/noteLock/NoteLockService';
|
|
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
|
|
import NoteLockPanel from './Note/NoteLockPanel';
|
|
|
|
interface Props {
|
|
themeId: number;
|
|
selectedNoteId: string;
|
|
noteLockSessionUnlocked: boolean;
|
|
|
|
// 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, canDecrypt: boolean) => {
|
|
const [note, setNote] = useState<NoteEntity|null>(null);
|
|
// The revision note as merged from the diffs, with a locked body still encrypted. Restoring
|
|
// this keeps the restored copy a valid locked note, so it is never decrypted for restore.
|
|
const [restoreNote, setRestoreNote] = useState<NoteEntity|null>(null);
|
|
const [decryptFailed, setDecryptFailed] = useState(false);
|
|
const [resources, setResources] = useState<AttachedResources>({});
|
|
|
|
useAsyncEffect(async event => {
|
|
// Cleared before the load so a switch never keeps the previous revision's failure or restore target.
|
|
setRestoreNote(null);
|
|
setDecryptFailed(false);
|
|
const revisionIndex = BaseModel.modelIndexById(revisions, revisionId);
|
|
if (revisionIndex === -1) {
|
|
setNote(null);
|
|
setResources({});
|
|
return;
|
|
}
|
|
let note = await RevisionService.instance().revisionNote(revisions, revisionIndex);
|
|
if (event.cancelled) return;
|
|
const encryptedNote = note;
|
|
if (isNoteLockEnabled() && revisions[revisionIndex].is_locked) {
|
|
// Revisions are merged before decryption, so a gated load is not possible.
|
|
// Keep this in sync with app-desktop/gui/NoteRevisionViewer.tsx.
|
|
let displayBody = '';
|
|
let failed = false;
|
|
if (canDecrypt) {
|
|
try {
|
|
displayBody = await NoteLockService.instance().decryptString(note.body ?? '');
|
|
} catch (error) {
|
|
console.warn('Could not decrypt revision content:', error);
|
|
failed = true;
|
|
}
|
|
if (event.cancelled) return;
|
|
}
|
|
setDecryptFailed(failed);
|
|
note = { ...note, body: displayBody };
|
|
}
|
|
setNote(note);
|
|
setRestoreNote(encryptedNote);
|
|
|
|
const resources = await attachedResources(note?.body ?? '');
|
|
if (event.cancelled) return;
|
|
setResources(resources);
|
|
}, [revisions, revisionId, canDecrypt]);
|
|
|
|
return { note, restoreNote, decryptFailed, 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,
|
|
},
|
|
titleViewContainer: {
|
|
paddingLeft: theme.marginLeft,
|
|
borderTopColor: theme.dividerColor,
|
|
borderTopWidth: 1,
|
|
borderBottomColor: theme.dividerColor,
|
|
borderBottomWidth: 1,
|
|
flex: 0,
|
|
flexDirection: 'row',
|
|
flexBasis: 'auto',
|
|
maxHeight: '40%',
|
|
},
|
|
titleText: {
|
|
flex: 1,
|
|
marginTop: 0,
|
|
paddingLeft: 0,
|
|
color: theme.color,
|
|
backgroundColor: theme.backgroundColor,
|
|
fontWeight: 'bold',
|
|
fontSize: theme.fontSize,
|
|
paddingTop: 10,
|
|
paddingBottom: 10,
|
|
},
|
|
});
|
|
}, [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 hasNoteLockKey = isNoteLockEnabled() && !!NoteLockKey.instance().load();
|
|
const canDecrypt = props.noteLockSessionUnlocked && hasNoteLockKey;
|
|
const { note, restoreNote, decryptFailed, resources } = useRevisionNote(revisions, currentRevisionId, canDecrypt);
|
|
const revisionLocked = isNoteLockEnabled() && revisions.some(r => r.id === currentRevisionId && !!r.is_locked);
|
|
const showLockPanel = revisionLocked && (!canDecrypt || decryptFailed);
|
|
const [initialScroll, setInitialScroll] = useState(0);
|
|
const [hasRevisions, setHasRevisions] = useState(false);
|
|
const [multiline, setMultiline] = useState(false);
|
|
const [showMultilineToggle, setShowMultilineToggle] = useState<boolean | null>(null);
|
|
const [titleContainerWidth, setTitleContainerWidth] = useState(0);
|
|
|
|
const options = useMemo(() => {
|
|
const result = [];
|
|
const reversedRevisions = revisions.slice().reverse();
|
|
for (const revision of reversedRevisions) {
|
|
const stats = Revision.revisionPatchStatsText(revision);
|
|
result.push({
|
|
label: `${formatMsToLocal(revision.item_updated_time)} (${stats})`,
|
|
value: revision.id,
|
|
});
|
|
}
|
|
setHasRevisions(result.length > 0);
|
|
return result;
|
|
}, [revisions]);
|
|
|
|
const onScroll: OnScrollCallback = useCallback((event) => {
|
|
setInitialScroll(event.fraction);
|
|
}, []);
|
|
|
|
const onOptionSelected = useCallback((value: string) => {
|
|
setCurrentRevisionId(value);
|
|
}, []);
|
|
|
|
const [restoring, setRestoring] = useState(false);
|
|
const onRestore = useCallback(async () => {
|
|
if (!restoreNote) return;
|
|
setRestoring(true);
|
|
try {
|
|
await RevisionService.instance().importRevisionNote(restoreNote);
|
|
await shim.showMessageBox(RevisionService.instance().restoreSuccessMessage(restoreNote), { type: MessageBoxType.Info });
|
|
} finally {
|
|
setRestoring(false);
|
|
}
|
|
}, [restoreNote]);
|
|
|
|
const resetScreenState = useCallback(() => {
|
|
setCurrentRevisionId(null);
|
|
setHasRevisions(false);
|
|
revisions.length = 0;
|
|
options.length = 0;
|
|
}, [revisions, options]);
|
|
|
|
const [deleting, setDeleting] = useState(false);
|
|
const deleteHistory_onPress = useDeleteHistoryClick({
|
|
noteId,
|
|
setDeleting,
|
|
resetScreenState,
|
|
});
|
|
|
|
const disableDeleteHistory = deleting || !hasRevisions;
|
|
const menuOptions = useMemo(() => {
|
|
const output: MenuOption[] = [{
|
|
title: _('Delete history'),
|
|
icon: 'material delete-outline',
|
|
style: MenuOptionStyle.Destructive,
|
|
onPress: deleteHistory_onPress,
|
|
disabled: disableDeleteHistory,
|
|
}];
|
|
|
|
return output;
|
|
}, [deleteHistory_onPress, disableDeleteHistory]);
|
|
|
|
const restoreButtonTitle = _('Restore');
|
|
const helpMessageText = getHelpMessage(restoreButtonTitle);
|
|
const dialogs = useContext(DialogContext);
|
|
const onHelpPress = useCallback(() => {
|
|
void dialogs.info(helpMessageText);
|
|
}, [helpMessageText, dialogs]);
|
|
const onToggleTitlePress = useCallback(() => {
|
|
void setMultiline(!multiline);
|
|
}, [multiline]);
|
|
|
|
const styles = useStyles(props.themeId);
|
|
const dropdownLabelText = _('Revision:');
|
|
|
|
const restoreButton = (
|
|
<PrimaryButton
|
|
onPress={onRestore}
|
|
disabled={restoring || !restoreNote || showLockPanel}
|
|
>{restoreButtonTitle}</PrimaryButton>
|
|
);
|
|
|
|
const textWrapCalculator_updateState = (showToggle: boolean, enableMultiline: boolean) => {
|
|
setShowMultilineToggle(showToggle);
|
|
setMultiline(enableMultiline);
|
|
};
|
|
|
|
const titleToggleButton = !showMultilineToggle ? null :
|
|
<IconButton
|
|
icon={(!multiline && 'menu-down') || (multiline && 'menu-up')}
|
|
accessibilityLabel={(!multiline && _('Expand title')) || (multiline && _('Collapse title'))}
|
|
onPress={onToggleTitlePress}
|
|
size={30}
|
|
style={{ width: 30, height: 30, alignSelf: 'center' }}
|
|
/>;
|
|
|
|
const titleComponent = (
|
|
<View
|
|
style={styles.titleViewContainer}
|
|
onLayout={(e) => {
|
|
const width = e.nativeEvent.layout.width;
|
|
if (width !== titleContainerWidth) {
|
|
setTitleContainerWidth(width);
|
|
}
|
|
}}
|
|
>
|
|
<TextWrapCalculator
|
|
textCompStyle={styles.titleText}
|
|
textCompContainerWidth={titleContainerWidth}
|
|
showMultilineToggle={showMultilineToggle}
|
|
multiline={multiline}
|
|
text={note?.title ?? ''}
|
|
updateState={textWrapCalculator_updateState}
|
|
readOnly={true}
|
|
/>
|
|
{
|
|
multiline ?
|
|
<ScrollView
|
|
style={{ flex: 1 }}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<TextNative
|
|
selectable
|
|
style={styles.titleText}
|
|
>
|
|
{note?.title ?? ''}
|
|
</TextNative>
|
|
</ScrollView> :
|
|
<TextInput
|
|
style={styles.titleText}
|
|
value={note?.title ?? ''}
|
|
editable={false}
|
|
/>
|
|
}
|
|
{ titleToggleButton }
|
|
</View>
|
|
);
|
|
|
|
return <View style={styles.root}>
|
|
<ScreenHeader menuOptions={menuOptions} 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...')}
|
|
// Hides the restore button when the dropdown is open. This
|
|
// is important on small screens where otherwise it's difficult
|
|
// to read the content of the revision dropdown.
|
|
coverableChildrenRight={restoreButton}
|
|
/>
|
|
<IconButton
|
|
icon='help-circle-outline'
|
|
accessibilityLabel={_('Help')}
|
|
onPress={onHelpPress}
|
|
/>
|
|
</View>
|
|
{note ? titleComponent : ''}
|
|
{showLockPanel ?
|
|
<NoteLockPanel
|
|
themeId={props.themeId}
|
|
hasNoteLockKey={hasNoteLockKey}
|
|
undecryptable={decryptFailed && props.noteLockSessionUnlocked}
|
|
/> :
|
|
<NoteBodyViewer
|
|
style={styles.noteViewer}
|
|
noteBody={note?.body ?? _('No revision selected')}
|
|
noteMarkupLanguage={MarkupLanguage.Markdown}
|
|
noteResources={resources}
|
|
highlightedKeywords={emptyStringList}
|
|
paddingBottom={0}
|
|
initialScrollPercent={initialScroll}
|
|
onScroll={onScroll}
|
|
noteHash={''}
|
|
/>
|
|
}
|
|
</View>;
|
|
};
|
|
|
|
export default connect((state: AppState) => ({
|
|
themeId: state.settings.theme,
|
|
selectedNoteId: state.selectedNoteIds[0] ?? '',
|
|
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
|
|
}))(NoteRevisionViewer);
|