Logo

dev-resources.site

for different kinds of informations.

Encrypted Note Editor App In React Native

Published at
3/4/2024
Categories
reactnative
richtext
editor
opensource
Author
17amir17
Author
8 person written this
17amir17
open
Encrypted Note Editor App In React Native

Hey everyone! If you're anything like me, you're probably always taking notes. It could be for class, personal reflections, or even those random poems that pop into your head at 3 AM. So, how cool would it be to build our own notes app? This app is going to be more than just a place to dump your thoughts. We're talking offline and persisted, encrypted, neat lists, text formatting and even a search function to find stuff easily enhanced with AI Image Captioning. Thanks to React Native and its awesome community, we can get this done without it taking ages.

I'm going to keep this guide straightforward and skip the super nitty-gritty details, because let’s face it, that would take all day and you want to see the juicy stuff. But don't worry, the entire app is open source. So, if you're curious about the bits and bobs of how everything works or want to dive deeper on your own, everything's available right here: https://github.com/10play/EncryptedNotesApp

Before we get into the code, let’s do a quick overview of the main packages we will use to make this app.

The Editor: The core of our app is the editor. We need an easy to use and robust rich text editor, that supports all of the features we want such as: headings, lists, placeholders, markdown, color, images, bold italic etc… For this we will use @10play/tentap-editor which is a rich text editor for react native based on Tiptap.

Storing the notes: For storing the notes, we will use the amazing WatermelonDB package which is a popular sqlite wrapper for react-native. Instead of using the default package we will use a fork of this that uses sqlcipher instead of the regular sqlite, allowing us to encrypt the database by passing a secret key.

Storing the secret key: Since our db requires a key, it is important to store that key somewhere secure, so we will use react-native-keychain which will store our key securely.

Camera and Image Captioning: For taking pictures we will use react-native-vision-camera and for generating captions from the images, react-native-quick-tflite.

Let’s get started!

Creating The Editor

First things first, let’s create the core of the app, the editor. We will be using TenTap. TenTap is based on TipTap, and comes with a bunch pre-built plugins, if we wanted to, we could create more such as mentions or drop cursors, but for now we just use the out of the box ones.

We will create a new component Editor.tsx and add our editor



export const Editor = () => {
 const editor = useEditorBridge({
   avoidIosKeyboard: true, 
   autofocus: true,
   initialContent: '<h1>Untitled Note</h1>',
 });


 return (
   <SafeAreaView style={{flex: 1}}>
     <RichText editor={editor} />
   </SafeAreaView>
 );
};


Enter fullscreen mode Exit fullscreen mode

We create our editor instance with useEditorBridge and pass the following params:
avoidIOSKeyboard- keep content above the keyboard on ios
autoFocus- autofocus the note and open the keyboard
initialContent - the initial content to display in the editor (eventually we will pass the note stored in our db)

Now we have an Editor component but it is pretty boring, let’s add a toolbar. The TenTap docs have a bunch of guides that show us how to do just this



   <SafeAreaView style={{flex: 1}}>
     <RichText editor={editor} />
     <KeyboardAvoidingView
       behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
       style={{
         position: 'absolute',
         width: '100%',
         bottom: 0,
       }}>
       <Toolbar
         editor={editor}
       />
     </KeyboardAvoidingView>
   </SafeAreaView>


Enter fullscreen mode Exit fullscreen mode

We add the toolbar in a KeyboardAvoidingView to keep it just above the keyboard, we could also make it static like in some mail apps.

TenTap allows us to customize the extensions, this can be anything from adding a custom editor schema, to adding a placeholder to adding light/dark/custom themes.

First let’s make our notes always start with a heading, to do this we will extendExtension of the CoreBridge to make it’s content enforce a heading node followed by many blocks.



 const editor = useEditorBridge({
   bridgeExtensions: [
     ...TenTapStartKit,
     CoreBridge.extendExtension({
       content: 'heading block+',
     })]
    ....


Enter fullscreen mode Exit fullscreen mode

Now, let’s add a placeholder to that first node



     CoreBridge.extendExtension({
       content: 'heading block+',
     }),
     PlaceholderBridge.configureExtension({
       showOnlyCurrent: false,
       placeholder: 'Enter a Title',
     }).configureCSS(`
       .ProseMirror h1.is-empty::before {
         content: attr(data-placeholder);
         float: left;
         color: #ced4da;
         height: 0;
       }
       `),


Enter fullscreen mode Exit fullscreen mode

Here we configure the Placeholder extension to add placeholders with the text of Enter a Title, we can then add some custom css to show the placeholder.

Now finally, let’s add some custom css to make it prettier!
We will start by creating a const with out custom css



const editorCss = `
 * {
   font-family: sans-serif;
 }
 body {
   padding: 12px;
 }
 img {
   max-width: 80%;
   height: auto;
   padding: 0 10%;
 }
`;


Enter fullscreen mode Exit fullscreen mode

And now we will configure the CoreBridge to use this css



CoreBridge.configureCSS(editorCss)


Enter fullscreen mode Exit fullscreen mode

Taking Pictures
To add images with searchable captions to the editor, we need to implement two things

  1. A way to take pictures and insert them into the editor, (i won’t be going into this, you can follow this blog https://dev.to/guyserfaty/rich-text-editor-with-react-native-upload-photo-3hgo)
  2. A way to generate captions from images, I used the example provided in react-native-quick-tflite see here

In the end, a new component called Camera, the Camera component will receive a function called onPhoto, when a picture is taken the callback will be called with
path: the path to the picture taken
captions: the captions generated from the picture.



<EditorCamera onPhoto={async (path, captions) => {
 // Add the image to the editor
 editor.setImage(`file://${photoPath}`);
 // Update the editors selection
 const editorState = editor.getEditorState();
 editor.setSelection(editorState.selection.from, editorState.selection.to);
 // Focus back to the editor
 editor.focus();
 // TODO - update the note’s captions
}} />


Enter fullscreen mode Exit fullscreen mode

If you want to take a deeper look at the caption implementation check this

Ok, now we have most of the editor setup let's start making it persistent

First thing is to get the content from the editor each time it changes, there are a couple of ways to get the content from the editor.

  1. Use the onChange param, which will be called each time the editor content is change and then use either editor.getHTML, editor.getText or editor.getJSON.
  2. Use the useEditorContent hook - monitors changes to the editor's content and then debounces the content

Both are viable options for us, but we will use the useEditorContent hook.
Since the useEditorContent hook will render each content change, we will create another component called Autosave and pass the editor and later on the note model there to avoid rendering the Editor component too much.



export const AutoSave = ({editor, note}: AutoSaveProps) => {
 const docTitle = useEditorTitle(editor);
 const htmlContent = useEditorContent(editor, {type: 'html'});
 const textContent = useEditorContent(editor, {type: 'text'});


 const saveContent = useCallback(
   debounce(
     async (note, title, html, text) => {
    // TODO save note
     },
   ),
   [],
 );


 useEffect(() => {
   if (htmlContent === undefined) return;
   if (docTitle === undefined) return;
   if (textContent === undefined) return;


   saveContent(note, docTitle, htmlContent, textContent);
 }, [note, saveContent, htmlContent, docTitle, textContent]);


 return null;
};


Enter fullscreen mode Exit fullscreen mode

Setting Up The Encrypted DB

As mentioned before, we will use a fork of WatermelonDB that uses SQLCipher instead of SQLite (won’t go into how this fork was made but If you are interested let me know!)

First let’s define our db’s schema



const schema = appSchema({
 tables: [
   tableSchema({
     name: NotesTable,
     columns: [
       {name: 'title', type: 'string'},
       {name: 'subtitle', type: 'string', isOptional: true},
       {name: 'html', type: 'string'},
       {name: 'captions', type: 'string', isIndexed: true},
       {name: 'text', type: 'string', isIndexed: true},
     ],
   }),
 ],
 version: 1,
});


Enter fullscreen mode Exit fullscreen mode

We save the text of the note in addition to the html, to give us the ability to later search for text in notes.

Now that we have the schema let’s create our Note Model



export class NoteModel extends Model {
 static table = NotesTable;


 @text(NoteFields.Title) title!: string;
 @text(NoteFields.Subtitle) subtitle?: string;
 @text(NoteFields.Html) html!: string;
 @text(NoteFields.Text) text!: string;
 @json(NoteFields.Captions, sanitizeCaptions) captions!: string[];


 @writer async updateNote(
   title: string,
   htmlContent: string,
   textContent: string,
 ) {
   await this.update(note => {
     note.title = title;
     note.html = htmlContent;
     note.text = textContent;
   });
 }


 @writer async updateCaptions(captions: string[]) {
   await this.update(note => {
     note.captions = captions;
   });
 }


 @writer async deleteNote() {
   await this.destroyPermanently();
 }
}


Enter fullscreen mode Exit fullscreen mode

We add some additional functionality into our note model, such as update for updating the note with new content, and updateCaptions for updating our notes captions.

Now let’s use react-native-keychain to get and set our db’s password.



import * as Keychain from 'react-native-keychain';


const createPassphrase = () => {
 // This is not safe at all, but for now we'll just use a random string
 return Math.random().toString(36);
};


export const getPassphrase = async () => {
 const credentials = await Keychain.getGenericPassword();
 if (!credentials) {
   const passphrase = createPassphrase();
   await Keychain.setGenericPassword('passphrase', passphrase);
   return passphrase;
 }
 return credentials.password;
};


Enter fullscreen mode Exit fullscreen mode

Connecting Everything Together

Now that we have our editor set up, and our database ready, all that is left is to connect the two.

First we will create a NoteList component, that queries all of our notes and renders them, with WaterMelonDB this is done with Observers



// Enhance our _NoteList with notes
const enhance = withObservables([], () => {
 const notesCollection = dbManager
   .getRequiredDB()
   .collections.get<NoteModel>(NotesTable);
 return {
   notes: notesCollection.query().observe(),
 };
});
const NotesList = enhance(_NotesList);


Enter fullscreen mode Exit fullscreen mode

This is an HOC that queries all of our notes and passes them as props to the _NotesList component, which can be implemented as follows



interface NotesListProps {
 notes: NoteModel[];
}
const _NotesList = ({notes}: NotesListProps) => {
 const renderNode: ListRenderItem<NoteModel> = ({item: note}) => (
   <NoteListButton
     onPress={() => {
    // Navigate to our editor with its the note
       navigate('Editor', {note});
     }}>
     <StyledText>{note.title || 'Untitled Note'}</StyledText>
     <DeleteButton onPress={() => note.deleteNote()}>
       <StyledText>Delete</StyledText>
     </DeleteButton>
   </NoteListButton>
 );
 return (
   <FlatList
     data={notes}
     renderItem={renderNode}
     keyExtractor={note => note.id}
   />
 );
};


Enter fullscreen mode Exit fullscreen mode

We also need to add a button that creates notes



     <CreateNoteButton
       onPress={async () => {
         await db.write(async () => {
           await db.collections.get<NoteModel>(NotesTable).create(() => {});
         });
       }}>


Enter fullscreen mode Exit fullscreen mode

Now we should see a new note added each time we create a new note, and if we press it, it should navigate us to the Editor with the NodeModel that we pressed. Because we have the note model now we can set the editors initial content to the html saved in the NoteModel.



 const editor = useEditorBridge({
   initialContent: note.html,


Enter fullscreen mode Exit fullscreen mode

Then in our auto save component we can call note.update



 const saveContent = useCallback(
   debounce(
     async (note: NoteModel, title: string, html: string, text: string) => {
       await note.updateNote(title, html, text); // <-- call the updateNote function we created on our model
     },
   ),
   [],
 );


Enter fullscreen mode Exit fullscreen mode

And let’s also update the captions in our onPhotoCallback



 const onPhoto = async (photoPath: string, captions: string[]) => {
   …
   const uniqcaptions = Array.from(new Set([...note.captions, ...captions]));
   await note.updateCaptions(uniqcaptions);
 };


Enter fullscreen mode Exit fullscreen mode

This is looking much much better! We have an editor with encrypted and persisted notes, all that is left is to add search!

We will add a new parameter to our Notes Observer call query, and query all the notes that contain the text or caption in the query:



const enhance = withObservables(['query'], ({query}: {query: string}) => {
 const notesCollection = dbManager
   .getRequiredDB()
   .collections.get<NoteModel>(NotesTable);
 return {
   notes: query
     ? notesCollection
         .query(
           Q.or([
             Q.where(NoteFields.Text, Q.like(`%${query}%`)),
             Q.where(NoteFields.Captions, Q.like(`%${query}%`)),
           ]),
         )
         .observe()
     : notesCollection.query().observe(),
 };
});


Enter fullscreen mode Exit fullscreen mode

Now the NotesList component is used like this:



     <SearchInput
       value={queryValue}
       onChangeText={text => {
         setQueryValue(text);
       }}
     />
     <NotesList query={queryValue} />


Enter fullscreen mode Exit fullscreen mode

That is it we're done!

Happy Cat

I tried to get as much information in this blog as possible without making it a super long read, so many boring things were left out, but as mentioned before all of the code is open source, so for better understanding check it out and run it yourself!

editor Article's
30 articles in total
Favicon
Implementing Image Upload in React Quill
Favicon
Lazyvim version 14.x in WSL
Favicon
Adding and Customizing Tables in React Quill
Favicon
C Development with GNU Emacs
Favicon
Building a Real-Time Collaborative Text Editor with Slate.js
Favicon
SLATE Code editor with highlight
Favicon
Emacs, a simple tour
Favicon
AI Video Editor: Revolutionizing Video Editing
Favicon
Choosing the editor for the next decade
Favicon
Effortless Formatting for OpenTofu Files with LazyVim
Favicon
Store and Run your Javascript Online - tryjs.online
Favicon
Chosing the right code editor: A quick guide
Favicon
Magic Spell - An AI-powered text editor built with Next.js and the Vercel AI SDK
Favicon
Easy Access to Terminal Commands in Neovim using FTerm
Favicon
Encrypted Note Editor App In React Native
Favicon
Fully featured, modern and extensible editor
Favicon
Set Up Neovim with kickstart.nvim on Mac as a Vimginner
Favicon
Working with Zed for a week
Favicon
Guide to using ‘ed’ editor in Linux
Favicon
Elevating Your Video Editing Experience
Favicon
How do you use your VSCode profile?
Favicon
Live Editor with React, Quill, and Socket.IO
Favicon
The Spectacular Transformation: VFX’s Role in Redefining Cinema
Favicon
The Evolution of Emacs: A Journey Through Time
Favicon
React Markdown Editor with real-time preview
Favicon
Online Code Editors
Favicon
A light weight code editor that helps as to code efficiently & swiftly in a 360 world
Favicon
I created overbyte - An Online code editor
Favicon
Build a Neovim plugin in Lua 🌙
Favicon
Helix and Zellij

Featured ones: