Building a Debate App: Part 3

By akohad Apr26,2023

[ad_1]

Decentralization / Social media

Adding messaging

Today we’re going to add the ability to send and receive messages for each side of a debate as exemplified in the above screenshot. We start from where we left off last time:

git clone https://github.com/jeremyorme/debate.git
cd debate
git checkout release/0.0.1

Collection manager

Let’s start with the data this time. We’ll need collections to hold messages for both the ‘for’ and ‘against’ sides of the debate so we need to update our AppData class to manage these.

These are implemented in the same way as the debates collection that we created last time. Instead of copy-pasting, let’s factor out that logic into a new class, CollectionManager<T>:

// Handles collection entries and notifying updates
export class CollectionManager<TEntry> {
private _collection: IDbCollection | null = null;
private _callbacks: (() => void)[] = [];
private _entries: TEntry[] = [];

init(dbCollection: IDbCollection) {
this._collection = dbCollection;
this._collection.onUpdated(() => this._notifyUpdated());
this._notifyUpdated();
}

private _notifyUpdated() {
if (!this._collection)
return;
this._entries = Array.from(this._collection.all).map(kvp => kvp[1])
for (const cb of this._callbacks)
cb();
}

entries(): TEntry[] {
return this._entries || [];
}

onUpdated(callback: () => void) {
this._callbacks.push(callback);
}

addEntry(entry: TEntry) {
this._collection?.insertOne(entry);
}
}

Now we can update AppData to use this class for the debates collection:

// Central point for accessing all the app's data
export class AppData {
private _db: IDb | null = null;

async init(db: IDb) {
this._db = db;
await this._loadDebates();
}

// Debates

private _debates: CollectionManager<IDebate> = new CollectionManager<IDebate>();

private async _loadDebates() {
if (!this._db)
return;

this._debates.init(await this._db.collection('debate', {
publicAccess: AccessRights.ReadWrite,
conflictResolution: ConflictResolution.FirstWriteWins
}));
}

debates(): IDebate[] {
return this._debates.entries();
}

onDebatesUpdated(callback: () => void) {
return this._debates.onUpdated(callback);
}

addDebate(debate: IDebate) {
this._debates.addEntry(debate);
}
}

In the last part, we subscribed to updates to the debates collection in the HomePage component as follows:

    appData.onDebatesUpdated(() => { setDebates(appData.debates()); });

This isn’t quite right because it means that every render will attach another callback. We could expect performance to slowly degrade due to these unnecessary callbacks. The right way to do this is to use React effects:

    useEffect(() => {
return appData.onDebatesUpdated(() => { setDebates(appData.debates()); });
}, []);

Now we will only attach a callback when the component is mounted. Note the addition of return. We return a callback that runs when the component is unmounted. When this happens we’d like to detach our update callback so we need to make the following change to CollectionManager<T>.onUpdated():

    onUpdated(callback: () => void) {
// Add the updated callback
this._callbacks.push(callback);

// Return a callback that removes the updated callback
return () => {
const index = this._callbacks.indexOf(callback);
if (index > -1)
this._callbacks.splice(index, 1);
}
}

It’s a bit of a nuisance that we have to write this notification code — I intend to subsume this back into IDbCollection.onUpdated so we can simply call it and return the clean-up callback that it returns.

Another issue is that if the component is mounted (and therefore the update handler is attached) after the collection update event then we’ll miss it and won’t see any data. We can fix this simply by setting our initial state value (debates) in our component (HomePage) to be initialized with the latest data (appData.debates()):

    const [debates, setDebates] = useState(appData.debates());

Finally, we should also have specified a key for our DebateCard components as this is required by React to give them a unique name:

                {debates.map(d => <DebateCard key={d._id} id={d._id} username={d._identity.publicKey.slice(-8)} title={d.title} description={d.description} url={findUrl(d.description)} />)}

Message collections

Now we’ve tidied up from last time, we can move on and add the collections that will hold our debate messages.

First we need to define the structure of a message. For now, this will just be a string description but we’ll define an interface so we can easily add more stuff later:

export interface IMessage extends IDbEntry {
description: string;
}

Let’s add a Map that associates each side string ('for' or 'against') with a CollectionManager<IMessage> instance:

    // Messages

private _messages: Map<string, CollectionManager<IMessage>> = new Map(
['for', 'against'].map(s => [s, new CollectionManager<IMessage>()]));

Next we need a method to load the collection of messages for a given debate and side:

    async loadMessages(debateId: string, side: string) {
if (!this._db)
return;

const collectionName = 'debate-' + debateId + '-messages-' + side;
this._messages.get(side)?.init(await this._db.collection(collectionName, {
publicAccess: AccessRights.ReadWrite,
conflictResolution: ConflictResolution.FirstWriteWins
}));
}

The loadMessages method is very similar to loadDebates except instead of a fixed collection name we construct it from the debateId andside strings — each debate side gets its own collection of messages.

Now we just need to add accessors to get the messages and add a new message the same as we did with debates:


messages(side: string): IMessage[] {
return this._messages.get(side)?.entries() || [];
}

onMessages(side: string, callback: () => void) {
return this._messages.get(side)?.onUpdated(callback);
}

addMessage(side: string, message: IMessage) {
this._messages.get(side)?.addEntry(message);
}

One final thing — we’ll show the title of the debate on the messages page so it would be nice if we could access the title of a single debate. Let’s add a method to AppData for this:

    debateTitle(debateId: string): string {
return this._debates.entry(debateId)?.title || '<< Loading >>';
}

Implementing the UI

For the UI we’ll start by adding a new page to show messages. Create a new empty file src/pages/MessagesPage.css and then a new file src/pages/MessagesPage.tsx setting up the basic layout:

import { IonBackButton, IonButton, IonButtons, IonCol, IonContent, IonGrid, IonHeader, IonIcon, IonInput, IonPage, IonRow, IonTitle, IonToolbar } from "@ionic/react";
import { arrowForwardSharp } from "ionicons/icons";
import DebateCard from "../components/DebateCard";
import './MessagesPage.css';

const MessagesPage: React.FC = () => {
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/home" />
</IonButtons>
<IonTitle>Side: Title</IonTitle>
</IonToolbar>
<IonGrid>
<IonRow>
<IonCol>
<IonInput placeholder="What do you think?" />
</IonCol>
<IonCol size="auto">
<IonButton fill="clear">
<IonIcon icon={arrowForwardSharp} />
</IonButton>
</IonCol>
</IonRow>
</IonGrid>
</IonHeader>
<IonContent>
<DebateCard key="id" username="username" description="description" url="" />)}
</IonContent>
</IonPage>
);
};

export default DebatePage;

We’re reusing the DebateCard component to display each message. However, we don’t need a title for a message so let’s make that an optional property:

interface ContainerProps {
title?: string;
description: string;
username: string;
url: string;
}

If title is not provided then we’ll skip rendering the IonCardTitle component:

                {title ? <IonCardTitle>{title}</IonCardTitle> : null}

Now we have a page, we need a way to get to it. First we’ll declare a new route in src/App.tsx:

                    <Route path="/debate/:id/messages/:side">
<MessagesPage appData={appData} />
</Route>

Next we’ll add a Link to our new page from the thumbs up and thumbs down icons in the DebateCard component. First we need to add id as an optional prop:

interface ContainerProps {
id?: string;
title?: string;
description: string;
username: string;
url: string;
}

Then we can add the links. We’ll only render these links if id was provided:

                    {id ? <IonItem>
<Link to={'/debate/' + id + '/messages/for'}>
<IonIcon size="small" icon={thumbsUpSharp} />
</Link>
<IonBadge className="count">11</IonBadge>
</IonItem> : null}
{id ? <IonItem>
<Link to={'/debate/' + id + '/messages/against'}>
<IonIcon size="small" icon={thumbsDownSharp} />
</Link>
<IonBadge className="count">11</IonBadge>
</IonItem> : null}

Side note: I discovered that it is very important to use React’s Link to navigate rather than a regular link because the latter causes the App to reload, which causes the database to reinitialize and is therefore very slow!

Populating with data

Now we can navigate to our page and it has the right layout, the next step is to fill it with data from the database.

First, we need to get hold of the route parameters (the debate id and side) so we know what data to fetch. We can use React’s useParams function:

    const { id, side } = useParams<ContainerParams>();

This requires us to define our parameters type:

interface ContainerParams {
id: string;
side: string;
}

With these parameters we can load the messages and the debate title and store them in state:

    const [debateTitle, setDebateTitle] = useState(appData.debateTitle(id));
const [messages, setMessages] = useState(appData.messages(side));

useEffect(() => {
appData.loadMessages(id, side);
return appData.onDebatesUpdated(() => {
setDebateTitle(appData.debateTitle(id));
appData.loadMessages(id, side);
});
}, []);

useEffect(() => {
return appData.onMessages(side, () => {
setMessages(appData.messages(side))
});
}, []);

We use useEffect here again to ensure that we only subscribe to updates when the component is mounted (not every time it is rendered).

The first effect sets up a listener for when the debate data is available at which point we store the title of the current debate and load its messages. We also kick off message loading before that event comes in, in case we subscribed too late and missed it.

The second effect sets up a listener for when new messages arrive and when that triggers we simply update the messages state to the latest value.

Now we have the data, we can populate the page title with the side (first letter capitalized) and debate title:

                    <IonTitle>{side.charAt(0).toUpperCase() + side.slice(1)}: {debateTitle}</IonTitle>

Then we can fill the content with messages:

                {messages.map(m => <DebateCard key={m._id} username={m._identity.publicKey.slice(-8)} description={m.description} url={findUrl(m.description)} />)}

We want to reuse our findUrl helper here so we’ll move it from the HomePage component into a new file src/Utils.ts and export it:

export const findUrl = (description: string) => {
const urlRegex = /(https?:\/\/[^ ]*)/g;
const match = description.match(urlRegex);
return match ? match[match.length - 1] : '';
}

Posting a new message

All that remains to implement is posting a new message to the chat. For this we’ll add another state variable to hold the message description and a helper (updateDescription) to update it, plus another helper (addMessage) to build the IMessage, call appData.addMessage with it and clear the input box:

    const [description, setDescription] = useState('');

const updateDescription = (value: string | null | undefined) => {
if (!value && value != '')
return;

setDescription(value);
};

const addMessage = () => {
const message: IMessage = {
...dbEntryDefaults,
description,
};
appData.addMessage(side, message);
setDescription('');
}

We just need to hook these into the IonInput and IonButton components:

                        <IonCol>
<IonInput placeholder="What do you think?" value={description} onIonChange={e => updateDescription(e.detail.value)} />
</IonCol>
<IonCol size="auto">
<IonButton fill="clear" disabled={description.length == 0} onClick={() => addMessage()}>
<IonIcon icon={arrowForwardSharp} />
</IonButton>
</IonCol>

Note that we also set up the disabled property on the IonButton to prevent accidentally sending and empty message by disabling the button when the description length is zero.

That’s it! You can get the full source to the end of this article from the 0.0.2 release branch:

git clone https://github.com/jeremyorme/debate.git
cd debate
git checkout release/0.0.2

Next time we’ll look at how to implement voting.

[ad_2]

Source link

By akohad

Related Post

Leave a Reply

Your email address will not be published. Required fields are marked *