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>
);
};
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>
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+',
})]
....
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;
}
`),
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%;
}
`;
And now we will configure the CoreBridge
to use this css
CoreBridge.configureCSS(editorCss)
Taking Pictures
To add images with searchable captions to the editor, we need to implement two things
- 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)
- 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
}} />
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.
- Use the
onChange
param, which will be called each time the editor content is change and then use eithereditor.getHTML
,editor.getText
oreditor.getJSON
. - 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;
};
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,
});
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();
}
}
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;
};
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);
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}
/>
);
};
We also need to add a button that creates notes
<CreateNoteButton
onPress={async () => {
await db.write(async () => {
await db.collections.get<NoteModel>(NotesTable).create(() => {});
});
}}>
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,
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
},
),
[],
);
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);
};
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(),
};
});
Now the NotesList component is used like this:
<SearchInput
value={queryValue}
onChangeText={text => {
setQueryValue(text);
}}
/>
<NotesList query={queryValue} />
That is it we're done!
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)