DEV Community

Cover image for Encrypted Note Editor App In React Native
Amir Angel
Amir Angel

Posted on

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!

Top comments (0)