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([]); 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(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(null); const [decryptFailed, setDecryptFailed] = useState(false); const [resources, setResources] = useState({}); 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 => { const noteId = props.navigation?.state?.noteId ?? props.selectedNoteId; const revisions = useRevisions(noteId); const [currentRevisionId, setCurrentRevisionId] = useState(''); 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(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 = ( {restoreButtonTitle} ); const textWrapCalculator_updateState = (showToggle: boolean, enableMultiline: boolean) => { setShowMultilineToggle(showToggle); setMultiline(enableMultiline); }; const titleToggleButton = !showMultilineToggle ? null : ; const titleComponent = ( { const width = e.nativeEvent.layout.width; if (width !== titleContainerWidth) { setTitleContainerWidth(width); } }} > { multiline ? {note?.title ?? ''} : } { titleToggleButton } ); return {dropdownLabelText} {note ? titleComponent : ''} {showLockPanel ? : } ; }; export default connect((state: AppState) => ({ themeId: state.settings.theme, selectedNoteId: state.selectedNoteIds[0] ?? '', noteLockSessionUnlocked: state.noteLockSessionUnlocked, }))(NoteRevisionViewer);