Compile outputs fun

Make a note taking app with Firestore - Part 2.2 Text editor

Published 2 years agoReactNative, Typescript

This is continued from Make a note taking app with Firestore - Part 2.1 Mobile application

Background

Web application is using an open source Markdown editor but I can't find a similar component for React Native. Thus the mobile application will only use a normal text editor. Markdown content is nicely formatted even if it's just displayed in plain text.

What to do?

1. Add navigation to the mobile application

We will use @react-navigation to add navigation to the mobile application. Run these commands to install the library:

npm i @react-navigation/native @react-navigation/stack

We will change the HomePanel to show a document list if a document is not selected, and a text editor if a document is selected. Replace ./src/components/HomePanel.tsx with these:

import firestore from '@react-native-firebase/firestore';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import React, { useContext, useMemo } from 'react';
import LoggedInContext from '../contexts/LoggedInContext';
import DocList from './DocList';
import EditPanel from './EditPanel';
import NewButton from './NewButton';
import LogoutButton from './LogoutButton';

const Stack = createStackNavigator();

const HomePanel = () => {
  const loggedIn = useContext(LoggedInContext);
  const uid = loggedIn.user!.uid;
  const docRefs = useMemo(() => firestore().collection('users').doc(uid).collection('docs'), [uid]);

  const logout = () => {
    loggedIn.clear?.apply(null);
  };

  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="List" options={{
          headerTitle: 'Write it down',
          headerLeft: () => (<NewButton docRefs={docRefs} />),
          headerRight: () => (<LogoutButton />),
      }}>
          {itemProps => <DocList {...itemProps} docRefs={docRefs} />}
        </Stack.Screen>
        <Stack.Screen name="Edit">
          {itemProps => <EditPanel {...itemProps} docRefs={docRefs} logout={logout} />}
        </Stack.Screen>
      </Stack.Navigator>
    </NavigationContainer>
  );
};

export default HomePanel;

Next we will implement the LogoutButton. It will logout the current user when user click on it. Create ./src/components/LogoutButton.tsx and put these:

import React, { useContext } from 'react';
import LoggedInContext from '../contexts/LoggedInContext';
import { Button } from 'react-native';

const LogoutButton = () => {
    const loggedIn = useContext(LoggedInContext);

    const logout = () => {
        loggedIn.clear?.apply(null);
    };

    return (<Button accessibilityLabel="Logout button" title="Logout" onPress={logout} />);
};

export default LogoutButton;

Next we will implement the NewButton. It will create a new document when user click on it. Create ./src/components/NewButton.tsx and put these:

import React from 'react';
import { Button } from 'react-native';
import { FirebaseFirestoreTypes } from '@react-native-firebase/firestore';
import { useNavigation } from '@react-navigation/native';

const NewButton = (props: {
    docRefs: FirebaseFirestoreTypes.CollectionReference,
}) => {
    const navigation = useNavigation();

    const newDoc = async () => {
        const docRef = await props.docRefs.add({ title: 'New', content: '' });
        navigation.navigate('Edit', { id: docRef.id });
    };

    return (<Button accessibilityLabel="New button" title="New" onPress={newDoc} />);
};

export default NewButton;

Next we will implement the DeleteButton. It will delete existing document when user click on it. This will be shown in the editor screen. Create ./src/components/DeleteButton.tsx and put these:

import React from 'react';
import { Button, Alert } from 'react-native';
import { FirebaseFirestoreTypes } from '@react-native-firebase/firestore';
import { useNavigation } from '@react-navigation/native';

const DeleteButton = (props: {
    docRef: FirebaseFirestoreTypes.DocumentReference,
}) => {
    const navigation = useNavigation();

    const deleteDoc = async () => {
        Alert.alert('Delete', 'Delete this?', [
            {
                text: 'Yes',
                style: 'destructive',
                onPress: async () => {
                    await props.docRef.delete();
                    navigation.navigate('List');
                },
            }, {
                text: 'No',
                style: 'cancel',
            },
        ]);
    };

    return (<Button accessibilityLabel="Delete button" title="Delete" onPress={deleteDoc} />);
};

export default DeleteButton;

Next we will implement the DocList. It will list out all the documents belong to the logged in user. Create ./src/components/DocList.tsx and put these:

import React, { useState, useEffect } from 'react';
import DocListItem from './DocListItem';
import { FlatList } from 'react-native';
import { FirebaseFirestoreTypes } from '@react-native-firebase/firestore';

const DocList = (props: {
    docRefs: FirebaseFirestoreTypes.CollectionReference,
}) => {
    const [list, setList] = useState<FirebaseFirestoreTypes.QueryDocumentSnapshot[]>();

    useEffect(() => {
        return props.docRefs.onSnapshot(newDocRefs => {
            setList(newDocRefs.docs.sort((a, b) => compare(a.data().title, b.data().title)));
        });
    }, [props.docRefs]);

    return (
        <FlatList accessibilityLabel="List" data={list}
            renderItem={itemProps => <DocListItem {...itemProps} doc={itemProps.item} />}
            keyExtractor={item => item.id} />
    );
};

function compare(a: any, b: any) {
    return typeof a === 'string' && typeof b === 'string' ? a.localeCompare(b) :
        !a && b ? -1 : a && !b ? 1 : a < b ? -1 : a > b ? 1 : 0;
}

export default DocList;

Next we will implement the DocListItem. It is just a touchable component to open the editor for the selected document when user click on it. Create ./src/components/DocListItem.tsx and put these:

import React from 'react';
import { Text, TouchableHighlight, View } from 'react-native';
import { FirebaseFirestoreTypes } from '@react-native-firebase/firestore';
import { useNavigation } from '@react-navigation/native';

const DocListItem = (props: {
    doc: FirebaseFirestoreTypes.QueryDocumentSnapshot,
}) => {
    const navigation = useNavigation();
    const data = props.doc.data();
    const title = data.title || '<untitled>';

    const editDoc = () => {
        navigation.navigate('Edit', { id: props.doc.id });
    };

    return (
        <TouchableHighlight accessibilityHint="Edit document" onPress={editDoc}>
            <View style={{ padding: 10 }}>
                <Text accessibilityLabel="Title text">{title}</Text>
            </View>
        </TouchableHighlight>
    );
};

export default DocListItem;

2. Add editor to the mobile application

First, we create ./src/components/EditPanel.tsx with these:

import React, { useEffect, useLayoutEffect, useMemo } from 'react';
import { FirebaseFirestoreTypes } from '@react-native-firebase/firestore';
import { TextInput } from 'react-native-gesture-handler';
import { RouteProp, ParamListBase, useNavigation } from '@react-navigation/native';
import { View, Button } from 'react-native';
import DeleteButton from './DeleteButton';
import LogoutButton from './LogoutButton';

const EditPanel = (props: {
    docRefs: FirebaseFirestoreTypes.CollectionReference,
    route: RouteProp<ParamListBase, string>,
    logout: () => void,
}) => {
    const navigation = useNavigation();
    const { id } = props.route.params as { id: string };
    const docRef = useMemo(() => props.docRefs.doc(id), [id, props.docRefs]);
    const [content, setContent] = React.useState('');

    const listDocs = () => {
        navigation.navigate('List');
    };

    let isChangingFromSnapshot = false;

    // TODO update Firestore document content if editor content changed

    useLayoutEffect(() => {
        navigation.setOptions({
            headerTitle: 'Write it down',
            headerLeft: () => (
                <Button title="Menu" onPress={listDocs} />
            ),
            headerRight: () => (
                <View style={{ flexDirection: 'row' }}>
                    <DeleteButton docRef={docRef} />
                    <LogoutButton />
                </View>
            ),
        });
    });

    // TODO update editor content if Firestore document content changed

    return (
        <TextInput accessibilityLabel="Editor" value={content} onChangeText={onChangeText}
            multiline={true} autoFocus={true} textAlignVertical="top" style={{ flexGrow: 1 }} />
    );
};

// TODO helper functions

export default EditPanel;

Next we need to populate the editor content from the Firestore document content. Replace // TODO update editor content if Firestore document content changed with these:

    useEffect(() => {
        let timer: any;

        const unsubscribeOnSnapshot = docRef.onSnapshot(doc => {
            if (!doc.metadata.hasPendingWrites) {
                clearTimeout(timer);
                timer = setTimeout(() => {
                    setContent(doc.data()?.content);
                }, 100);
            }
        });

        return () => {
            unsubscribeOnSnapshot();
            clearTimeout(timer);
        };
    }, [docRef]);

This will subscribe to the snapshot event emitted from the Firestore document whenever the content is changed. It can be triggered due to changes from other instances of the web application. Thus the web application can sync the changes across different instances. We don't need to update for every event, we just need to latest one. So we will throttle the event to make it less CPU intensive. The event handler will update the editor content with the latest Firestore document content. The useEffect hook will unsubscribe the event when it's not needed anymore.

Next we will add event handler to update the Firestore document content when the editor content is changed. Replace // TODO update Firestore document content if editor content changed with these:

    const onChangeText = async (newContent: string) => {
        if (!isChangingFromSnapshot) {
            setContent(newContent);
            const newDoc = { title: extractTitle(newContent), content: newContent };
            await docRef.set(newDoc);
        }
    };

This will create an event handler for the change text event emitted from the editor. The event handler will update the Firestore document content with the editor content. Remember that we update the editor content when the Firestore document content is changed. It will trigger this event also. So we need the isChangingFromSnapshot flag to skip the event handler if it's triggered due to Firestore document content is changed.

Next we will add some helper functions to extract the title from the content. Replace // TODO helper functions with these:

function extractTitle(content: string) {
    const match = extractFirstLine(content)?.match(/^[^0-9a-zA-Z]*(.*)$/);
    return match && match[1];
}

function extractFirstLine(content: string) {
    return content && content.split('\n')
        .filter(l => !l.match(/^\s+$/))
        .shift();
}

This will extract the first line from the content as the title.

Now you have a note taking mobile application with sync capability. You can start the mobile application by running this command:

npm run android

The full source code is available at https://github.com/chimin/write-it-down .