Mobile: Resolves #16002: Add note lock UI - #4
Conversation
| const isProvisionalNote = comp.props.provisionalNoteIds.includes(comp.props.noteId); | ||
|
|
||
| const note = await Note.load(comp.props.noteId); | ||
| let note = await Note.load(comp.props.noteId); |
There was a problem hiding this comment.
Avoid calling a full load twice. Do a load for just the is_locked field first to get the value
There was a problem hiding this comment.
done, is_locked-only load first, full load happens once
| note = await Note.load(comp.props.noteId, { useNoteLock: true }); | ||
| noteLockKey = NoteLockSession.instance().decryptedKey(); | ||
| } else { | ||
| // Both note and lastSavedNote carry the same blank body, so a diff-based save can |
There was a problem hiding this comment.
This does not guarantee safety. Clearing the body here is still a risk of data loss. In order to prevent the cipher text ever showing in the note, what I think you can do is in Note.tsx, in the render function, override the bodyComponent variable when the body should be blocked, with a component which is just a blank area (the background colour should match the theme).
There was a problem hiding this comment.
removed the blanking, both state notes keep the encrypted body so a diff save can't write it, and the panel hides it in the ui
| // The recreate save strips the decrypted-state marker, but the recreated body is the | ||
| // in-memory plaintext, so the marker must be restored for a gated save. | ||
| const handleNoteDeletedKeepingDecryptedState_ = async (note: NoteEntity) => { | ||
| const recreatedNote = await shared.handleNoteDeletedWhileEditing_(note); |
There was a problem hiding this comment.
The wrapper function is unnecessary and currently poses a risk of persisting the body unencrypted while is_locked = 1, so if the app crashes immediately after, the note will be unreadable in the UI because it will fail to decrypt (but it is recoverable in the db). handleNoteDeletedWhileEditing_ needs to be altered directly to use both a gated save and a gated load, then it removes the save risk and populates isDecrypted by itself.
There was a problem hiding this comment.
wrapper's gone,handleNoteDeletedWhileEditing_ does a gated save and gated load directly when the flag is on
| }); | ||
| } | ||
|
|
||
| private async flushSavesAndAutoLock_(noteId: string) { |
There was a problem hiding this comment.
There's no need to put this into a function. flushSavesAndAutoLock_ is only used in 1 place
| private async flushSavesAndAutoLock_(noteId: string) { | ||
| await this.saveActionQueue(noteId).processAllNow(); | ||
| // Auto lock only after the queue drains, so a pending locked-note save can still encrypt. | ||
| if (isNoteLockEnabled() && Setting.value('noteLock.lockOnNoteSwitch')) { |
There was a problem hiding this comment.
Why are you doing this on unmount instead of on load? Was it just more practical to do so?
If you follow a link to another locked note within a locked note, will the unlock ui show with the setting enabled? If it doesn't, it should really be consistent with desktop (locks on every navigation), and be made to lock on load instead. But if unmount does fire every time when navigating through notes, then it should be fine to leave as is.
Also, if you toggle between view and edit mode, and switch between markdown and rich text editor, does the note stay unlocked, or does it show the unlock ui? The expectation is it should stay unlocked with those switches.
There was a problem hiding this comment.
unmount does fire on note-to-note navigation, screen goes back then forward when the note id changes, so following a link to another locked note shows the unlock ui. view/edit toggle and switching editors both keep it unlocked
| // The lock state may change between scheduling and execution (e.g. encryption enabled | ||
| // from the note menu), so the save uses the latest values. | ||
| note.is_locked = comp.state.note.is_locked; | ||
| if ('isDecrypted' in comp.state.note) (note as Record<string, unknown>).isDecrypted = (comp.state.note as Record<string, unknown>).isDecrypted; |
There was a problem hiding this comment.
Ditch the if condition and make isDecrypted always get set to the value on the comp.state.note here
| // An ungated partial body save would persist the plaintext of a locked note (e.g. a | ||
| // checkbox toggled in the viewer), so the body goes through a gated save instead. | ||
| toSave.is_locked = note.is_locked; | ||
| if ('isDecrypted' in note) toSave.isDecrypted = (note as Record<string, unknown>).isDecrypted; |
There was a problem hiding this comment.
Replace with:
(toSave as Record<string, unknown>).isDecrypted = (note as Record<string, unknown>).isDecrypted;
| const saved = await Note.save(toSave) as Record<string, unknown>; | ||
|
|
||
| let saved: Record<string, unknown>; | ||
| if (isNoteLockEnabled() && name === 'body' && NoteLockNote.isLocked(note)) { |
There was a problem hiding this comment.
Please put this whole if/else block into a new private function called saveNotePartial which returns saved
There was a problem hiding this comment.
done. one note: right after disabling, a checkbox toggle could still do an ungated body save while the db row hasn't flipped yet (until the scheduled save lands), same transient window class as the event-driven flow, left as is
| // Without this, the state update below would revert a lock state change made during | ||
| // the save, and the already scheduled lock save would pick up the reverted value. | ||
| note.is_locked = stateNote.is_locked; | ||
| if ('isDecrypted' in stateNote) (note as Record<string, unknown>).isDecrypted = (stateNote as Record<string, unknown>).isDecrypted; |
There was a problem hiding this comment.
Ditch the if condition and make isDecrypted always get set to the value on the stateNote here
| if (isNoteLockEnabled() && comp.state.note?.id === note.id) { | ||
| // The lock state may have changed during the save - keep the latest value in the state | ||
| // note (but not in lastSavedNote, so the next save still detects the change). | ||
| stateNote.is_locked = comp.state.note.is_locked; |
There was a problem hiding this comment.
Also add this here too:
(stateNote as Record<string, unknown>).isDecrypted = (comp.state.note as Record<string, unknown>).isDecrypted;
|
The mobile app additionally has a share function in the hamburger menu. Similar to print, you should make it disabled for locked notes when the session is locked or the note is undecryptable, but enabled when the session is unlocked, and perform a gated loading before outputting the shared contents |
396c2f2 to
daf2fe4
Compare
|
Don't force push please, now I have to review this all over again |
|
Please look into how to undo rebase using reflog, as this quite a mess now and I've lost the history of what you had before. Please backup your current changes to a new branch, the revert the rebase and force push it back to how it was |
sorry, that was the rebase for the note-screen-shared move, i can force push it back to the exact commit you reviewed so your comments reanchor, or leave it as is,either way no more force pushes after that, everything else comes as normal commits |
|
Backup you branch and revert to exactly how it was. There is no need to rebase, do merges instead |
daf2fe4 to
396c2f2
Compare
|
An additional suggestion for change: |
|
Superseded by the upstream PR: laurent22#16069. The reviewed work is its first commit, with the newer changes as separate commits. |


Summary
Adds the mobile note lock UI behind the existing feature flag. Issue: laurent22#16002. Stacked on laurent22#15961.
Enable/Disable encryption and Lock encrypted notes live in the note screen menu (no note list actions on mobile, long press is multi-select). Encrypted notes show a padlock in the note list. A locked note opens read-only with the locked panel instead of the body. Enabling with no password redirects to config (dead route until laurent22#15963 merges), and enabling while locked shows the unlock form in a modal.
The mobile note screen drives everything from its state note, so the shared load/save path handles the gating. A locked note keeps its encrypted body in state while the session is locked (the UI hides it), decrypts on unlock, and pending saves re-encrypt with the key captured at load. Saves write lock state, encrypted body and extracted resource ids together and read the latest lock state at execution time. Viewer checkbox saves go through the gated path too, and a note deleted while open is recreated without losing its decrypted state.
Share is disabled for locked notes while the session is locked or the note is undecryptable; sharing an unlocked note uses a gated load.
Enable/disable now persist through the note screen's scheduled save (event-driven, matching desktop).
With the flag off, the note screen behaves exactly as before.
Testing
yarn tscyarn workspace @joplin/lib test(note-screen-shared, noteLock)yarn workspace @joplin/app-mobile test Note.testVideo
Full run-through:
video_20260721_175500.mp4
AI Assistance Disclosure
I used AI tools while working on this PR for code suggestions and review, checking scope and tests, and drafting parts of this description, including the disclosure. I reviewed the final changes and reran the tests listed above myself.