Building a Debate App: Part 5

By akohad Apr26,2023

[ad_1]

Decentralization / Social media

Displaying vote counts

Last time we added the ability to vote on a debate and to see the status of our own vote but we couldn’t see how many others had voted. In this part we’ll fix that by showing the vote counts next to the thumbs up/down icons in the debate list as shown in the above screenshot. We start from where we left off:

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

Firstly, we need to update bonono-react to at least 0.5.4:

npm update bonono-react

That’s because Bonono 0.5.3 did not provide a means of properly disposing of a DbCollection — it would remain attached to the Db instance and continue to consume resources. As we want to load many votes collections (for the debates in our list) this could cause performance issues.

Splitting Debate and Message cards

So far we’ve been using DebateCard for displaying a debate and also a message. Now we need to make it more debate specific because it’s going to look up the vote numbers for a debate. Therefore, let’s make a copy DebateCard called MessageCard and use that for messages instead.

The contents of src/components/MessageCard.css will be exactly the same as src/components/DebateCard.css. In src/components/MessageCard.tsx we need to update the name DebateCard to MessageCard in the following places:

// ...
import './MessageCard.css';
// ...

const MessageCard: React.FC<ContainerProps> = ({ id, title, description, username, url }) => {
// ...

export default MessageCard;

Then in src/components/MessagesPage.tsx, we import and use MessageCard instead of DebateCard in a couple of places:

// ...
import MessageCard from "../components/MessageCard";
// ...

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

Multiple vote collections in AppData

We need to open the vote collections for a debate when it scrolls on screen so that we can show its vote counts. There may be more than one debate visible at a time so we need a replace our single votes collection with a map:

    // Votes

private _votes: Map<string, CollectionManager<IVote>> = new Map();

The map is initially empty so we’ll add a helper to return an existing collection or create and insert a new one if none exists for the given debateId:

    private _votesCollection(debateId: string): CollectionManager<IVote> {
let votesCollection = this._votes.get(debateId);
if (!votesCollection) {
votesCollection = new CollectionManager<IVote>();
this._votes.set(debateId, votesCollection);
}
return votesCollection;
}

We’ll modify loadVotes to only call init on the collection if it wasn’t already initialized:

    async loadVotes(debateId: string) {
if (!this._db)
return;

const collectionName = 'debate-' + debateId + '-votes';
const collection = this._votesCollection(debateId);
if (collection.ready())
return;
collection.init(await this._db.collection(collectionName, {
publicAccess: AccessRights.ReadAnyWriteOwn,
conflictResolution: ConflictResolution.LastWriteWins
}));
}

Note this requires that we add a new ready() method to our CollectionManager<T> class to determine if it was previously initialized:

    ready(): boolean {
return !!this._collection;
}

Now we can call loadVotes each time we encounter a new debate to load its vote data. We can’t just keep opening more and more collections though. If we were to keep open the vote collection for every debate we scroll past then the number of open collections would become a performance issue.

Therefore, we’d like to close a collection when we are no longer displaying its data (in this case the vote counts). For this purpose, we add a closeVotes method:

    closeVotes(debateId: string) {
this._votesCollection(debateId).close();
this._votes.delete(debateId);
}

This closes the collection and deletes it from our map. We need to add the close method to CollectionManager<T> to propagate the close to the DbCollection:

    close() {
this._collection?.close();
}

We’re almost done with the changes to AppData. We just need to call our helper this._votesCollection(debateId) in place of this._votes in the other vote methods and pass in debateId as a parameter to each of them:

    votes(debateId: string): IVote[] {
return this._votesCollection(debateId).entries() || [];
}

onVotes(debateId: string, callback: () => void) {
return this._votesCollection(debateId).onUpdated(callback);
}

addVote(debateId: string, message: IVote) {
if (this._publicKey)
this._votesCollection(debateId).addEntry({ ...message, _id: this._publicKey });
}

ownVoteDirection(debateId: string): VoteDirection {
if (!this._publicKey)
return VoteDirection.Undecided;
return this._votesCollection(debateId).entry(this._publicKey)?.direction || VoteDirection.Undecided;
}

We need to update the calls to these methods in src/pages/MessagesPage.tsx to include the debate id:

    // ...

const [ownVoteDirection, setOwnVoteDirection] = useState(appData.ownVoteDirection(id));

// ...

useEffect(() => {
return appData.onVotes(id, () => {
setOwnVoteDirection(appData.ownVoteDirection(id));
});
}, []);

// ...

const updateOwnVoteDirection = (newDirection: VoteDirection) => {
const direction = newDirection != ownVoteDirection ? newDirection : VoteDirection.Undecided;
const vote: IVote = {
...dbEntryDefaults,
direction
};
appData.addVote(id, vote);
setOwnVoteDirection(direction);
}

// ...

Finally, we need to add a couple of methods to count up the votes for and against for a given debate:

    votesFor(debateId: string): number {
return this.votes(debateId).reduce((c, v) => c + v.direction == VoteDirection.For ? 1 : 0, 0);
}

votesAgainst(debateId: string): number {
return this.votes(debateId).reduce((c, v) => c + v.direction == VoteDirection.Against ? 1 : 0, 0);
}

Displaying vote counts in DebateCard

Now we have everything in place to open and close vote collections and generate vote counts we can implement the UI changes. First, we need to install a package:

npm install react-intersection-observer

This package provides a hook to detect when an element scrolls in or out of the viewport and we’ll use that to open and close the collection.

In src/components/DebateCard.tsx, we’re going to pass the AppData object into the component so update ComponentProps (note we also make id and title mandatory now that we’re no longer sharing this component with MessagesPage):

interface ContainerProps {
appData: AppData,
id: string;
title: string;
// ...
}

Then add appData to the DebateCard component:

const DebateCard: React.FC<ContainerProps> = ({ appData, id, title, description, username, url }) => {

Now we can add state variables for the for and against vote counts:

    const [votesFor, setVotesFor] = useState(appData.votesFor(id));
const [votesAgainst, setVotesAgainst] = useState(appData.votesAgainst(id));

We can register for updates from the votes collections and update our state variables:

    useEffect(() => {
return appData.onVotes(id, () => {
setVotesFor(appData.votesFor(id));
setVotesAgainst(appData.votesAgainst(id));
});
});

Then we can use these values (votesFor and votesAgainst) when rendering the component (note we drop the check for id being falsy now it is a mandatory property):

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

While we’re at it, we can assume that title is set now as well and drop the null check:

                <IonCardTitle>{title}</IonCardTitle>

Now we just need to open and close the vote collection based on the visibility of the DebateCard. Let’s use the in-view hook from react-intersection-observer:

    const { ref, inView } = useInView();

The inView variable will be set to the in-view status (true or false) of the element referred to by ref. We initialize ref as follows:

        <IonCard ref={ref}>

Now we can use an effect that triggers on changes to inView to open and close the collection:

    useEffect(() => {
if (inView)
appData.loadVotes(id);
else
appData.closeVotes(id);
}, [inView]);

When our DebateCard scrolls into view, we’ll open the collection and start listening for updates to it and when it scrolls out of view we’ll close it down and stop receiving updates.

All that remains is to pass AppData into the DebateCard in src/pages/HomePage.tsx:

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

And there we go! We have efficient vote counts displayed on our debate list page. You can get the full source to the end of this article from the 0.0.4 release branch:

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

Next time we’ll look at adding the presentations page!

[ad_2]

Source link

By akohad

Related Post

Leave a Reply

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