[ad_1]
Decentralization / Social media
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:
// Messagesprivate _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