diff --git a/packages/app-mobile/components/ScreenHeader/index.tsx b/packages/app-mobile/components/ScreenHeader/index.tsx index ccf1b7976..e15013640 100644 --- a/packages/app-mobile/components/ScreenHeader/index.tsx +++ b/packages/app-mobile/components/ScreenHeader/index.tsx @@ -36,7 +36,8 @@ const PADDING_V = 10; type OnPressCallback=()=> void; export interface FolderPickerOptions { - enabled: boolean; + visible: boolean; + disabled?: boolean; selectedFolderId?: string; onValueChange?: OnValueChangedListener; mustSelect?: boolean; @@ -515,10 +516,12 @@ class ScreenHeaderComponent extends PureComponent { + const createTitleComponent = (hideableAfterTitleComponents: ReactElement) => { const folderPickerOptions = this.props.folderPickerOptions; - if (folderPickerOptions && folderPickerOptions.enabled) { + if (folderPickerOptions && folderPickerOptions.visible) { + const hasSelectedNotes = this.props.selectedNoteIds.length > 0; + const disabled = this.props.folderPickerOptions.disabled ?? !hasSelectedNotes; return ( ; - const titleComp = createTitleComponent(headerItemDisabled, hideableRightComponents); + const titleComp = createTitleComponent(hideableRightComponents); const contextMenuStyle: ViewStyle = { paddingTop: PADDING_V, diff --git a/packages/app-mobile/components/screens/Note.tsx b/packages/app-mobile/components/screens/Note.tsx index 447408d1e..3ca4dbc9f 100644 --- a/packages/app-mobile/components/screens/Note.tsx +++ b/packages/app-mobile/components/screens/Note.tsx @@ -47,7 +47,7 @@ import { isSupportedLanguage } from '../../services/voiceTyping/vosk'; import { ChangeEvent as EditorChangeEvent, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events'; import { join } from 'path'; import { Dispatch } from 'redux'; -import { RefObject, useContext } from 'react'; +import { RefObject, useContext, useRef } from 'react'; import { SelectionRange } from '../NoteEditor/types'; import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils'; import { AppState } from '../../utils/types'; @@ -1383,7 +1383,8 @@ class NoteScreenComponent extends BaseScreenComponent imp public folderPickerOptions() { const options = { - enabled: !this.state.readOnly, + visible: !this.state.readOnly, + disabled: false, selectedFolderId: this.state.folder ? this.state.folder.id : null, onValueChange: this.folderPickerOptions_valueChanged, }; @@ -1391,7 +1392,7 @@ class NoteScreenComponent extends BaseScreenComponent imp if ( this.folderPickerOptions_ && options.selectedFolderId === this.folderPickerOptions_.selectedFolderId - && options.enabled === this.folderPickerOptions_.enabled + && options.visible === this.folderPickerOptions_.visible ) { return this.folderPickerOptions_; } @@ -1649,9 +1650,19 @@ class NoteScreenComponent extends BaseScreenComponent imp // which can cause some bugs where previously set state to another note would interfere // how the new note should be rendered const NoteScreenWrapper = (props: Props) => { + const lastNonNullNoteIdRef = useRef(props.noteId); + if (props.noteId) { + lastNonNullNoteIdRef.current = props.noteId; + } + + // This keeps the current note open even if it's no longer present in selectedNoteIds. + // This might happen, for example, if the selected note is moved to an unselected + // folder. + const noteId = lastNonNullNoteIdRef.current; + const dialogs = useContext(DialogContext); return ( - + ); }; diff --git a/packages/app-mobile/components/screens/Notes.tsx b/packages/app-mobile/components/screens/Notes.tsx index 08e8d869a..befda92f1 100644 --- a/packages/app-mobile/components/screens/Notes.tsx +++ b/packages/app-mobile/components/screens/Notes.tsx @@ -214,11 +214,11 @@ class NotesScreenComponent extends BaseScreenComponent { public folderPickerOptions() { const options = { - enabled: this.props.noteSelectionEnabled, + visible: this.props.noteSelectionEnabled, mustSelect: true, }; - if (this.folderPickerOptions_ && options.enabled === this.folderPickerOptions_.enabled) return this.folderPickerOptions_; + if (this.folderPickerOptions_ && options.visible === this.folderPickerOptions_.visible) return this.folderPickerOptions_; this.folderPickerOptions_ = options; return this.folderPickerOptions_; diff --git a/packages/app-mobile/components/screens/SearchScreen/index.tsx b/packages/app-mobile/components/screens/SearchScreen/index.tsx index 137ccb3c0..fb03ba14d 100644 --- a/packages/app-mobile/components/screens/SearchScreen/index.tsx +++ b/packages/app-mobile/components/screens/SearchScreen/index.tsx @@ -86,7 +86,7 @@ const SearchScreenComponent: React.FC = props => { { expect(await Folder.count()).toBe(1); }); + it('should not auto-delete read-only items', async () => { + const shareId = 'testShare'; + + // Simulates a folder having been deleted a long time ago + const longTimeAgo = 1000; + + const readOnlyFolder = await Folder.save({ + title: 'Read-only folder', + share_id: shareId, + deleted_time: longTimeAgo, + }); + const readOnlyNote1 = await Note.save({ + title: 'Read-only note', + parent_id: readOnlyFolder.id, + share_id: shareId, + deleted_time: longTimeAgo, + }); + const readOnlyNote2 = await Note.save({ + title: 'Read-only note 2', + share_id: shareId, + deleted_time: longTimeAgo, + }); + const writableNote = await Note.save({ + title: 'Editable note', + deleted_time: longTimeAgo, + }); + + const cleanup = simulateReadOnlyShareEnv(shareId); + await permanentlyDeleteOldItems(Day); + + // Should preserve only the read-only items. + expect(await Folder.load(readOnlyFolder.id)).toBeTruthy(); + expect(await Note.load(readOnlyNote1.id)).toBeTruthy(); + expect(await Note.load(readOnlyNote2.id)).toBeTruthy(); + expect(await Note.load(writableNote.id)).toBeFalsy(); + + cleanup(); + }); }); diff --git a/packages/lib/services/trash/permanentlyDeleteOldItems.ts b/packages/lib/services/trash/permanentlyDeleteOldItems.ts index 8fbccc106..73ddaa977 100644 --- a/packages/lib/services/trash/permanentlyDeleteOldItems.ts +++ b/packages/lib/services/trash/permanentlyDeleteOldItems.ts @@ -4,9 +4,44 @@ import Setting from '../../models/Setting'; import Note from '../../models/Note'; import { Day, Hour } from '@joplin/utils/time'; import shim from '../../shim'; +import { itemIsReadOnlySync } from '../../models/utils/readOnly'; +import BaseItem from '../../models/BaseItem'; +import { ModelType } from '../../BaseModel'; +import ItemChange from '../../models/ItemChange'; const logger = Logger.create('permanentlyDeleteOldData'); +const readOnlyItemsRemoved = async (itemIds: string[], itemType: ModelType) => { + const result = []; + for (const id of itemIds) { + const item = await BaseItem.loadItem(itemType, id); + + // Only do the share-related read-only checks. If other checks are done, + // readOnly will always be true because the item is in the trash. + const shareChecksOnly = true; + const readOnly = itemIsReadOnlySync( + itemType, + ItemChange.SOURCE_UNSPECIFIED, + item, + Setting.value('sync.userId'), + BaseItem.syncShareCache, + shareChecksOnly, + ); + if (!readOnly) { + result.push(id); + } + } + return result; +}; + +const itemsToDelete = async (ttl: number|null = null) => { + const result = await Folder.trashItemsOlderThan(ttl); + const folderIds = await readOnlyItemsRemoved(result.folderIds, ModelType.Folder); + const noteIds = await readOnlyItemsRemoved(result.noteIds, ModelType.Note); + + return { folderIds, noteIds }; +}; + const permanentlyDeleteOldItems = async (ttl: number = null) => { ttl = ttl === null ? Setting.value('trash.ttlDays') * Day : ttl; @@ -17,13 +52,13 @@ const permanentlyDeleteOldItems = async (ttl: number = null) => { return; } - const result = await Folder.trashItemsOlderThan(ttl); - logger.info('Items to permanently delete:', result); + const toDelete = await itemsToDelete(ttl); + logger.info('Items to permanently delete:', toDelete); - await Note.batchDelete(result.noteIds, { sourceDescription: 'permanentlyDeleteOldItems' }); + await Note.batchDelete(toDelete.noteIds, { sourceDescription: 'permanentlyDeleteOldItems' }); // We only auto-delete folders if they are empty. - for (const folderId of result.folderIds) { + for (const folderId of toDelete.folderIds) { const noteIds = await Folder.noteIds(folderId, { includeDeleted: true }); if (!noteIds.length) { logger.info(`Deleting empty folder: ${folderId}`);