[ad_1]
Decentralization / Social media
Implementing the debate list
In this part we’re going to start implementing our debate app UI using React and its database using Bonono for React. The first thing we’ll implement is the list of debates and a modal form to create a new debate as shown in the above screenshot.
To minimise the effort required to build a nice looking UI, we’ll use the Ionic Framework. First, we’ll install the Ionic CLI, create a tabs starter project called debate
and build/run it:
npm install -g @ionic/cli
ionic start debate tabs --type=react
cd debate
ionic serve
Home page tab
We’re going to modify “Tab 1” to be our home page so let’s edit the route, tab icon and title in src/App.tsx
to reflect that:
return <IonApp>
<IonReactRouter>
<IonTabs>
<IonRouterOutlet>
<Route exact path="/home">
<HomePage />
</Route>
...
<Route exact path="/">
<Redirect to="/home" />
</Route>
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="home" href="/home">
<IonIcon icon={homeSharp} />
<IonLabel>Home</IonLabel>
</IonTabButton>
...
</IonTabBar>
</IonTabs>
</IonReactRouter>
</IonApp>
That requires importing the homeSharp
icon instead of triangle
and renaming the import of Tab1
to HomePage
:
import { ellipse, homeSharp, square } from 'ionicons/icons';
import HomePage from './pages/HomePage';
We also rename src/pages/Tab1.*
to src/pages/HomePage.*
and replace Tab1
with HomePage
in src/pages/HomePage.tsx
:
import './HomePage.css';const HomePage: React.FC = () => {
...
};
export default HomePage;
Header component
The first thing we need for our home page is the header that sits at the top:
Add src/components/DebateHeader.tsx
with the following code to create a toolbar with an avatar on the left and app icon in the middle:
import { IonAvatar, IonHeader, IonIcon, IonItem, IonToolbar } from '@ionic/react';
import './DebateHeader.css';
import { chatbubbles } from 'ionicons/icons';const DebateHeader: React.FC = () => {
return (
<IonHeader>
<IonToolbar>
<IonItem>
<IonAvatar slot="start"><img src="https://ionicframework.com/docs/img/demos/avatar.svg"/></IonAvatar>
<IonIcon className="app-icon" icon={chatbubbles}></IonIcon>
</IonItem>
</IonToolbar>
</IonHeader>
);
};
export default DebateHeader;
For now we’re just using a generic head and shoulders placeholder for the avatar image. Add src/components/DebateHeader.css
containing this small snippet that is required to correctly align the app icon:
.app-icon {
position: absolute;
top: 50%;
left: 50%;
margin: -12px -42px;
}
Replace the first <IonHeader>
in src/pages/HomePage.tsx
with:
<DebateHeader />
We need to import DebateHeader
:
import DebateHeader from '../components/DebateHeader';
The header will now be displayed at the top of the home page.
Debate card
Next, we need to create a card component that displays a summary of a debate. To make things a bit more interesting, we’re going to add an embedded video player for which we’ll use the react-player
package. We’ll install it in the usual way:
npm install react-player
We’ll put the following code in src/components/DebateCard.tsx
to display the card details including the video player (but only if a playable link is provided in the description):
import { IonAvatar, IonBadge, IonCard, IonCardContent, IonCardHeader, IonCardTitle, IonIcon, IonItem, IonLabel, IonList, IonText, IonToolbar } from '@ionic/react';
import './DebateCard.css';
import ReactPlayer from 'react-player';
import { heartSharp, peopleSharp, starSharp, thumbsDownSharp, thumbsUpSharp } from 'ionicons/icons';interface ContainerProps {
title: string;
description: string;
username: string;
url: string;
}
const DebateCard: React.FC<ContainerProps> = ({ title, description, username, url }) => {
return (
<IonCard>
<IonCardHeader>
<IonItem className="head-item" lines="none">
<IonAvatar slot="start"><img src="https://ionicframework.com/docs/img/demos/avatar.svg" /></IonAvatar>
<IonLabel color="medium"><strong>@{username}</strong> - Just now</IonLabel>
</IonItem>
<IonCardTitle>{title}</IonCardTitle>
</IonCardHeader>
<IonCardContent>
<p>{description}</p>
</IonCardContent>
{url && ReactPlayer.canPlay(url) ? <div className='player-wrapper'>
<ReactPlayer className='react-player' url={url} width='100%' height='100%' />
</div> : null}
<IonToolbar>
<IonItem className="counts">
<IonItem>
<IonIcon size="small" icon={thumbsUpSharp} />
<IonBadge className="count">11</IonBadge>
</IonItem>
<IonItem>
<IonIcon size="small" icon={thumbsDownSharp} />
<IonBadge className="count">11</IonBadge>
</IonItem>
<IonItem>
<IonIcon size="small" icon={starSharp} />
<IonBadge className="count">11</IonBadge>
</IonItem>
<IonItem>
<IonIcon size="small" icon={heartSharp} />
<IonBadge className="count">11</IonBadge>
</IonItem>
<IonItem>
<IonIcon size="small" icon={peopleSharp} />
<IonBadge className="count">1</IonBadge>
</IonItem>
</IonItem>
</IonToolbar>
</IonCard>
);
};
export default DebateCard;
We also need a little CSS in src/components/DebateCard.tsx
to make the video player scale correctly and to clean up the counters:
.head-item {
--padding-start: 0;
}.player-wrapper {
position: relative;
padding-top: 56.25%
}
.react-player {
position: absolute;
top: 0;
left: 0;
}
.counts {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.count {
--background: rgba(0, 0, 0, 0);
--color: var(--ion-color-medium);
}
Replace the <IonContent>
in src/pages/HomePage.tsx
with:
<IonContent fullscreen>
<DebateCard username="xyzabc" title="My debate" description="All about my debate" url="https://www.youtube.com/embed/jaJuwKCmJbY" />
</IonContent>
We need to import DebateCard
:
import DebateCard from '../components/DebateCard';
The home page will now display a single debate card.
Floating action button
Finally, we need to add a floating action button for adding a new debate (add this just below the IonContent
in src/pages/HomePage.tsx
)
<IonFab slot="fixed" vertical="bottom" horizontal="end">
<IonFabButton>
<IonIcon icon={add}/>
</IonFabButton>
</IonFab>
We’ll also need to update the Ionic imports and import the add
icon:
import { IonContent, IonFab, IonFabButton, IonIcon, IonPage } from '@ionic/react';
import { add } from 'ionicons/icons';
With the changes so far, the home page tab will display a page that looks very similar to the left hand screenshot at the top of this article. Next we need to implement the form to add a new debate.
Add-debate modal
The add-debate modal is the form on the right of the screenshot at the top of the article. This form provides fields to enter the details of a new debate and buttons to add the debate or cancel the operation.
Let’s create a modal component in src/components/DebateAddModal.tsx
with all the fields we need (also add an empty src/components/DebateAddModal.css
file):
import { IonAvatar, IonButton, IonButtons, IonContent, IonDatetime, IonHeader, IonIcon, IonInput, IonItem, IonItemDivider, IonLabel, IonModal, IonTextarea, IonToolbar } from '@ionic/react';
import { add, peopleSharp, remove } from 'ionicons/icons';
import './DebateAddModal.css';interface ContainerProps {
isOpen: boolean;
setIsOpen: (x: boolean) => void;
}
const DebateAddModal: React.FC<ContainerProps> = ({ isOpen, setIsOpen }) => { return (
<IonModal isOpen={isOpen}>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonButton onClick={() => setIsOpen(false)}>Cancel</IonButton>
</IonButtons>
<IonButtons slot="end">
<IonButton>
<IonIcon icon={add}/>
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent>
<IonItem>
<IonAvatar slot="start"><img src="https://ionicframework.com/docs/img/demos/avatar.svg"/></IonAvatar>
<IonInput placeholder="What say you?"/>
</IonItem>
<IonItem>
<IonTextarea autoGrow={true} placeholder="Tell me more..."/>
</IonItem>
<IonItemDivider>
<IonLabel>Voting groups</IonLabel>
</IonItemDivider>
<IonItem>
<IonIcon slot="start" icon={peopleSharp} />
<IonInput color="medium" value="Default" readonly={true}/>
<div style={{ 'width': '50px' }}>
<IonInput type="number" color="medium" value="75" readonly={true}/>
</div>
<IonLabel color="medium">%</IonLabel>
<IonButton slot="end">
<IonIcon icon={add}/>
</IonButton>
</IonItem>
<IonItem>
<IonIcon slot="start" icon={peopleSharp} />
<IonInput color="medium" placeholder="Group name" value="My group"/>
<div style={{ 'width': '50px' }}>
<IonInput type="number" color="medium" value="25"/>
</div>
<IonLabel color="medium">%</IonLabel>
<IonButton slot="end">
<IonIcon icon={remove} />
</IonButton>
</IonItem>
<IonItemDivider>
<IonLabel>Schedule</IonLabel>
</IonItemDivider>
<IonDatetime presentation="date-time" preferWheel={true} size="cover">
<span slot="title">Start time</span>
</IonDatetime>
<IonDatetime presentation="date-time" preferWheel={true} size="cover">
<span slot="title">End time</span>
</IonDatetime>
</IonContent>
</IonModal>
);
};
export default DebateAddModal;
To show this modal, we’ll need to add it to the HomePage
component and implement the onClick
event on the floating action button:
<IonFab slot="fixed" vertical="bottom" horizontal="end">
<IonFabButton onClick={() => setIsOpen(true)}>
<IonIcon icon={add}/>
</IonFabButton>
</IonFab>
<DebateAddModal isOpen={isOpen} setIsOpen={setIsOpen} />
We need to create an isOpen
boolean state variable and its setter in HomePage
:
const [isOpen, setIsOpen] = useState(false);
Then import DebateAddModal
and useState
:
import DebateAddModal from '../components/DebateAddModal';
import { useState } from 'react';
This gives us something that looks like the right hand screenshot at the top of the article when we click the + button on the home page.
Add or remove groups
We want to be able to add or remove groups. To achieve this we add state to DebateAddModal
to hold the currently entered group details:
const [groups, setGroups] = useState(new Array<IGroup>());
We need to define the IGroup
interface. Create a new file src/AppData.ts
and add the following definition:
export interface IGroup {
name: string;
percent: number;
}
We’ll import this into src/components/DebateAddModal.tsx
along with useState
:
import { IGroup } from '../AppData';
import { useState } from 'react';
Let’s first update the default group so that its percentage set to whatever is left after subtracting all the other group percentages from 100%. We’ll need to calculate the total percentage of the specified groups:
const totalPercent = groups.map(g => g.percent).reduce((p, c) => p + c, 0);
Now we can set the default group value to 100 - totalPercent
. Note that if totalPercent >= 100
, the default group is not displayed as its percentage would be zero:
{totalPercent < 100 ? <IonItem>
<IonIcon slot="start" icon={peopleSharp} />
<IonInput color="medium" value="Default" readonly={true} />
<div style={{ 'width': '50px' }}>
<IonInput type="number" color="medium" value={100 - totalPercent} readonly={true} />
</div>
<IonLabel color="medium">%</IonLabel>
<IonButton slot="end" onClick={() => setGroups([...groups, { name: '', percent: 0 }])}>
<IonIcon icon={add} />
</IonButton>
</IonItem> : null}
We can now render the UI components for each group by mapping from the groups
array:
{groups.map((g, i) => <IonItem>
<IonIcon slot="start" icon={peopleSharp} />
<IonInput color="medium" placeholder="Group name" value={g.name}/>
<div style={{ 'width': '50px' }}>
<IonInput type="number" color="medium" value={g.percent}/>
</div>
<IonLabel color="medium">%</IonLabel>
<IonButton slot="end" onClick={() => setGroups([...groups.slice(0, i), ...groups.slice(i + 1)])}>
<IonIcon icon={remove} />
</IonButton>
</IonItem>)}
Note we also added an onClick
handler for the remove button that calls setGroups
with an array of all groups except the one at the current index.
The IonInput
components now take their values from the group. We just need to handle updating the stored values for each input. Let’s add a couple of helpers to update the name and percent values in the groups array:
const updateName = (i: number, value: string | null | undefined) => {
if (!value)
return;setGroups([...groups.slice(0, i), { name: value, percent: groups[i].percent }, ...groups.slice(i + 1)])
};
const updatePercent = (i: number, value: string | null | undefined) => {
if (!value)
return;
let newPercent = parseFloat(value);
if (Number.isNaN(newPercent))
return;
// Limit total % to 100
const oldPercent = groups[i].percent;
if (totalPercent - oldPercent + newPercent > 100)
newPercent = 100 - (totalPercent - oldPercent);
setGroups([...groups.slice(0, i), { name: groups[i].name, percent: newPercent }, ...groups.slice(i + 1)])
};
Note that updatePercent
limits the percent to prevent the groups adding to more than 100%.
We add an onIonChange
handler to each inputs that calls the relevant helper:
<IonInput color="medium" placeholder="Group name" value={g.name} onIonChange={e => updateName(i, e.detail.value)} />
<div style={{ 'width': '50px' }}>
<IonInput type="number" color="medium" value={g.percent} onIonChange={e => updatePercent(i, e.detail.value)} />
</div>
Next, we’ll add state for the title, description, start time and end time as we’ll need these values when we add the debate to the database:
const today = new Date();
const addDay = (d: Date) => {
const d_next = new Date();
d_next.setDate(d.getDate() + 1);
return d_next;
}const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [startTime, setStartTime] = useState(today);
const [endTime, setEndTime] = useState(addDay(today));
We set the end time to be a day after the start time to avoid creating a debate that immediately ends.
We add helper functions to update the state values provided the supplied value is valid:
const updateTitle = (value: string | null | undefined) => {
if (!value && value != '')
return;setTitle(value);
};
const updateDescription = (value: string | null | undefined) => {
if (!value && value != '')
return;
setDescription(value);
};
const updateStartTime = (value: string | string[] | null | undefined) => {
const s = value as string;
if (!s && s != '')
return;
setStartTime(new Date(s));
};
const updateEndTime = (value: string | string[] | null | undefined) => {
const s = value as string;
if (!s && s != '')
return;
setEndTime(new Date(s));
};
Now we can use the state values and updater functions in the components:
...
<IonInput placeholder="What say you?" value={title} onIonChange={e => updateTitle(e.detail.value)} />
...
<IonTextarea autoGrow={true} placeholder="Tell me more..." value={description} onIonChange={e => updateDescription(e.detail.value)} />
...
<IonDatetime presentation="date-time" preferWheel={true} size="cover" min={today.toISOString()} value={startTime.toISOString()} onIonChange={e => updateStartTime(e.detail.value)}>
...
<IonDatetime presentation="date-time" preferWheel={true} size="cover" min={addDay(startTime).toISOString()} value={endTime.toISOString()} onIonChange={e => updateEndTime(e.detail.value)}>
...
Note that we also set the min
property for the IonDatetime
to constrain the start time to be from now and the end time to be a day after the start time.
One last thing before we get into the database. The add button should be disabled if title
is empty. We can fix this using the disabled
property on the IonButton
:
<IonButton disabled={title.length == 0}>
<IonIcon icon={add}/>
</IonButton>
We’ve implemented the UI for listing the existing debates and for adding a new one. Now it’s time to hook this up to a Bonono data store so we can store and retrieve debates from our peer-to-peer database.
Installing IPFS and Bonono for React
We need to include IPFS using the following script tag in public/index.html
:
<script src="https://cdn.jsdelivr.net/npm/[email protected]/index.min.js"></script>
Next we need to install the bonono-react package:
npm install bonono-react
Adding the BononoDb component
We need to import BononoDb
and IDbClient
:
import { BononoDb, IDbClient } from 'bonono-react';
Now we can add the BononoDb
component to src/App.tsx
just before the router. We’ll use the bonono webrtc-star dev server to connect the swarm:
<BononoDb address="/dns4/nyk.webrtc-star.bonono.org/tcp/443/wss/p2p-webrtc-star/" onDbClient={e => loadDb(e.detail)} />
Loading the database
The onDbClient
property tells the component to invoke our loadDb
function, in which we’ll connect, open the database and pass it to appData.init
:
const loadDb = async (dbClient: IDbClient | null) => {
if (!dbClient)
return;if (!await dbClient.connect())
return;
const db = await dbClient.db("debate-app");
if (!db)
return;
appData.init(db);
}
Loading collections
We’ll define appData
as a state variable initialized to a new AppData
instance:
const [appData] = useState(new AppData());
We import AppData
and useState
:
import { AppData } from './AppData';
import { useState } from 'react';
Then we define our AppData
class to open a debates collection (public read/write, first write wins) and provide access to it in src/AppData.ts
as follows:
export class AppData {
private _debatesCollection: IDbCollection | null = null;
private _debateCallbacks: (() => void)[] = [];
private _debates: IDebate[] = [];private _notifyDebatesUpdated() {
if (!this._debatesCollection)
return;
this._debates = Array.from(this._debatesCollection.all).map(kvp => kvp[1]);
for (const cb of this._debateCallbacks)
cb();
}
async init(db: IDb) {
this._debatesCollection = await db.collection("debate", { publicAccess: AccessRights.ReadWrite, conflictResolution: ConflictResolution.FirstWriteWins });
this._debatesCollection.onUpdated(() => this._notifyDebatesUpdated());
this._notifyDebatesUpdated()
}
debates(): IDebate[] {
return this._debates;
}
onDebatesUpdated(callback: () => void) {
this._debateCallbacks.push(callback);
}
addDebate(debate: IDebate) {
this._debatesCollection?.insertOne(debate);
}
}
This requires a few imports:
import { AccessRights, ConflictResolution, IDb, IDbCollection } from "bonono-react";
An we need to define the IDebate
interface:
export interface IDbEntry {
_id: string;
_clock: number;
_identity: { publicKey: string };
}
export const dbEntryDefaults = { _id: '', _clock: 0, _identity: { publicKey: '' } };export interface IGroup {
name: string;
percent: number;
}
export interface IDebate extends IDbEntry {
title: string;
description: string;
startTime: string;
endTime: string;
groups: IGroup[];
}
We re-use the IGroups
interface that we created earlier. Note that we derive from IDbEntry
— this provides access to several fields that are automatically populated by Bonono.
We could have derived from Partial<IDbEntry>
to avoid having to set those fields when creating a new debate but then we’d have to check for null
/undefined
in a bunch of places. The dbEntryDefaults
object is provided as a quick way to pre-populate the IDbEntry
fields with empty values.
Passing collections around
We only have one collection so far but AppData
can be extended to contain all the collections that our app needs. This is convenient for passing them down to our pages or components.
We can pass our AppData
into the HomePage
component in src/App.tsx
:
<Route exact path="/home">
<HomePage appData={appData} />
</Route>
We need to add the appData
property to the HomePage
component:
interface ContainerProps {
appData: AppData;
}const HomePage: React.FC<ContainerProps> = ({ appData }) => {
...
};
And import AppData
in src/pages/HomePage.tsx
:
import { AppData } from '../AppData';
We can then pass it down further to the DebateAddModal
component:
<DebateAddModal appData={appData} isOpen={isOpen} setIsOpen={setIsOpen} />
We need to add the appData
property to the DebateAddModal
component:
interface ContainerProps {
appData: AppData;
isOpen: boolean;
setIsOpen: (x: boolean) => void;
}const DebateAddModal: React.FC<ContainerProps> = ({ appData, isOpen, setIsOpen }) => {
...
};
And again update our imports to include AppData
:
import { AppData, IGroup } from '../AppData';
Adding a debate to the collection
This is now as simple as adding the following helper to DebateAddModal
:
const addDebate = () => {
const debate: IDebate = {
...dbEntryDefaults,
title,
description,
groups,
startTime: startTime.toISOString(),
endTime: endTime.toISOString()
};
appData.addDebate(debate);
setIsOpen(false);
}
Updating the imports to include IDebate
and dbEntryDefaults
:
import { AppData, dbEntryDefaults, IDebate, IGroup } from '../AppData';
And then invoking addDebate
from the add button’s onClick
handler:
<IonButton disabled={title.length == 0} onClick={() => addDebate()}>
<IonIcon icon={add} />
</IonButton>
Reading debates from the collection
This is also fairly simple now we’ve done the hard work. Firstly, let’s create a state variable in HomePage
to hold the array of debates:
const [debates, setDebates] = useState(new Array<IDebate>());
Import IDebate
:
import { AppData, IDebate } from '../AppData';
Next, we’ll listen for updates to the debates
collection, updating our state variable when the collection changes:
appData.onDebatesUpdated(() => { setDebates(appData.debates()); });
Now we can simply map our debates array to DebateCard
components:
{debates.map(d => <DebateCard username={d._identity.publicKey.slice(-8)} title={d.title} description={d.description} url={findUrl(d.description)} />)}
Notice that we’ve used the last 8 digits of the user’s public key as their username. That’s because we haven’t implemented unique usernames yet so this is a stop-gap to show some kind of user identity.
The URL is extracted from the description using a findUrl
helper function:
const findUrl = (description: string) => {
const urlRegex = /(https?:\/\/[^ ]*)/g;
const match = description.match(urlRegex);
return match ? match[match.length - 1] : '';
}
Summary
We’ve made some good progress here — we’re now able to create debates, list them and share them with other users through a peer-to-peer data collection but there’s a lot more to do!
Next time we’ll look at implementing the chat rooms for each side of the debate.
The complete source code up to the end of this article is available at: jeremyorme/debate at release/0.0.1 (github.com)
[ad_2]
Source link