Decentralization / Social media

Building a Debate App: Part 13

Showing user voting records

Jeremy Orme
Published in
7 min readMay 18, 2023

--

In this part, we’re going to look at creating a user profile page, displaying the identity and voting record for a particular user. This will show:

  • A list of the groups they’ve voted in
  • A list of the votes they’ve cast (most recent first)
  • Their public key

We pick up from where we left off last time:

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

Why show a user’s voting record?

In the last part, we enabled people to specify affinity to a particular group when casting their vote. This supposes that the voter will correctly identify their group rather than selecting the group that maximizes their vote weight.

The ability for anyone to view a user’s voting record provides an extra incentive for them to identify correctly because other users can easily see inconsistency, which would be perceived as a lack of integrity. Therefore, this motivates them to be consistent to preserve their reputation.

Accessing user votes efficiently

Votes are stored in a sub-collection for a particular debate. As it stands, if we want to load all the votes for a user, we need to load the vote sub-collections for all debates and extract the votes for the user of interest. If the number of debates becomes large then this could take an unacceptably long time.

To get around this, we can additionally write votes to another collection that holds the vote history for a particular user. If we do this, we only need to load that single collection to access the user’s complete vote history.

This data redundancy can be tricky to manage if updates are required because multiple copies have to be updated in sync. Fortunately, we are dealing with immutable data in this case — archived debates are set in stone.

Preventing falsified user votes

A separate user votes collection solves the performance issue but this poses a different problem: what if the user writes to the vote collection for the debate but doesn’t write to their own vote history collection? Suppose, for example, they want to cast a vote in an inappropriate group and mask that from their history. Clearly, we can’t rely on a user to correctly update their own history.

An alternative to the user updating their history is for the debate owner to update it when the debate is complete and is archived. But… if anyone can append to the user’s history then anyone could put false votes in there. Fortunately, we can prevent that by having the voter add a cryptographic signature to their vote that can be validated to reject any invalid votes.

Even with these checks, the debate owner could conspire with other users to not record their voting history. A possible solution to this is to have some randomly selected debate participants verify the users’ voting histories after the debate is archived, appending any votes from the debate that were missed by the debate owner.

Archived votes

Let’s start with the collection that holds archived votes by user. First we need a new data type that augments IVote with the name of the identified group (we don’t want to have to look this up from the debate as that would involve loading the corresponding debate collection for each vote). We create the following interface in src/app-data/IArchivedVote.ts:

export interface IArchivedVote extends IVote {
groupName: string;
}

Next, we add a new sub-collection to PageData to hold the archived votes for each user. This uses access rules everyoneAppend because we’re going to let anyone append valid votes:

export class PageData {

//...
private _archivedVotes: SubCollection<IArchivedVote> = new SubCollection('user', 'archivedvotes', everyoneAppend);

init(db: IDb, selfPublicKey: string) {
// ...
this._archivedVotes.init(db, selfPublicKey);
// ...
}

// ...
get archivedVotes() { return this._archivedVotes; }
// ...
}

User page

We need a new page to put the vote information on. This will be a page for a particular user that contains useful information pertaining to them. Let’s add the skeleton page to src/pages/UserPage.ts:

interface ContainerProps {
pageData: PageData;
}

interface ContainerParams {
id: string;
}

const UserPage: React.FC<ContainerProps> = ({ pageData }) => {
const { id } = useParams<ContainerParams>();

return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/home" />
</IonButtons>
<IonTitle>@{id.slice(-8)}</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
</IonContent>
</IonPage>
);
};

export default UserPage;

This gives us the basic toolbar with the back button and title:

Next, we add the segment control to choose between sub-pages:

                <IonSegment value={userSubPage} onIonChange={e => updateUserSubPage(e.detail.value)}>
{Object.keys(UserSubPage).map(s => <IonSegmentButton key={s} value={s}>{s}</IonSegmentButton>)}
</IonSegment>

The sub-pages are defined in an enum:

enum UserSubPage {
Groups = 'Groups',
Votes = 'Votes',
Identity = 'Identity'
}

And we store the current sub page in a state variable:

    const [userSubPage, setUserSubPage] = useState(UserSubPage.Groups);

Then we need to add the updateUserSubPage function to update the state variable when the segment changes:

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

const userSubPage: UserSubPage = (UserSubPage as any)[value];
setUserSubPage(userSubPage);
};

Finally, we add an IonContent section for each sub-page:

            {userSubPage == UserSubPage.Groups ? <IonContent>
<IonItemDivider>
<IonLabel>Voted in groups</IonLabel>
</IonItemDivider>
</IonContent> : null}
{userSubPage == UserSubPage.Votes ? <IonContent>
<IonItemDivider>
<IonLabel>Cast votes</IonLabel>
</IonItemDivider>
</IonContent> : null}
{userSubPage == UserSubPage.Identity ? <IonContent>
<IonItemDivider>
<IonLabel>Public key</IonLabel>
</IonItemDivider>
</IonContent> : null}

We can now switch between the pages and it looks like this:

Identity sub-page

We add the user’s public key to the identity sub-page:

            {userSubPage == UserSubPage.Identity ? <IonContent>
<IonItemDivider>
<IonLabel>Public key</IonLabel>
</IonItemDivider>
<IonItem>
<IonLabel>{id}</IonLabel>
</IonItem>
</IonContent> : null}

It might be useful to see the full public key if there is a clash with another user when only considering the last 8 characters that are shown normally.

Votes sub-page

To show the votes, we need to load the user votes data from the collection we added:

    useEffect(() => {
return pageData.onInit(() => {
pageData.archivedVotes.load(id);
});
}, []);

useEffect(() => {
return () => {
pageData.archivedVotes.close(id);
};
}, []);

useEffect(() => {
return pageData.archivedVotes.onUpdated(id, () => {
setUserVotes(pageData.archivedVotes.entries(id).filter(v => v.direction != VoteDirection.Undecided));
});
}, []);

Note that we filter out any undecided votes as these are inconsequential and so not worth displaying — and we don’t want to include them when showing what groups have been identified with.

The user’s votes are loaded into a state variable:

    const [userVotes, setUserVotes] = useState(pageData.archivedVotes.entries(id));

Now we can display those votes on the votes sub-page:

            {userSubPage == UserSubPage.Votes ? <IonContent>
<IonItemDivider>
<IonLabel>Cast votes</IonLabel>
</IonItemDivider>
{userVotes.map(g => <IonItem>
<IonLabel>{g.direction == VoteDirection.For ? 'For' : 'Against'} ({g.groupName}) <Link to={'/debate/' + g._id + '/presentations'}>{g._id}</Link></IonLabel>
</IonItem>)}
</IonContent> : null}

For now, we are just displaying the raw debate id — in a later article we’ll look at changing this to show the debate title. Note that we don’t have a page for a debate so for now we link to the debate’s presentations, we’ll fix this later too.

Groups sub-page

The groups sub-page aggregates the votes to show names of all the groups that the user has identified with when voting. First we need to aggregate the groups on a votes update:

    useEffect(() => {
const groupNames: Set<string> = new Set();
for (const userVote of userVotes) {
groupNames.add(userVote.groupName);
}
setUserGroups(Array.from(groupNames.values()));
}, [userVotes]);

We set the user groups in a new state variable:

    const [userGroups, setUserGroups] = useState([] as string[]);

Now we can display them on the groups page:

            {userSubPage == UserSubPage.Groups ? <IonContent>
<IonItemDivider>
<IonLabel>Voted in groups</IonLabel>
</IonItemDivider>
{userGroups.map(g => <IonItem>
<IonLabel>{g}</IonLabel>
</IonItem>)}
</IonContent> : null}

Linking to the user page

First, let’s add a PageData instance for our new page:

export class AppData {
// ...
private _user: PageData = new PageData();

init(db: IDb, selfPublicKey: string) {
// ...
this._user.init(db, selfPublicKey);
}

// ...
get user() { return this._user; }
}

Then we can add a new route to our user page in src/App.tsx:

                    <Route path="/user/:id">
<UserPage pageData={appData.user} />
</Route>

And we can update our avatar (in src/components/DebateCard.tsx) to link to user page:

                    <IonAvatar slot="start">
<Link to={'/user/' + debate._identity.publicKey}>
<img className="avatar" src="https://ionicframework.com/docs/img/demos/avatar.svg" />
</Link>
</IonAvatar>

We also need to add a new avatar class to src/components/DebateCard.css because unfortunately, wrapping the <img> in a <Link> messes up the application of the round border.

.avatar {
border-radius: 50%;
overflow: hidden;
display: block;
height: 40px;
}

Writing the user voting history

All that remains is to actually write the votes into the user’s history when a debate transitions to being archived. To do this we add the following to the transitionDebate function in src/pages/HomePage.tsx:

            // Update user voting history for each voter
pageData.votes.entries(id).map(async v => {
const voterId = v._identity.publicKey;
await pageData.archivedVotes.load(voterId);
const groupName = d.debate.groups[v.groupIdx].name;
pageData.archivedVotes.addEntry(voterId, { ...v, _id: id, groupName });
pageData.archivedVotes.close(voterId);
});

This requires opening, writing and closing a vote collection for every vote cast in the debate, which could be expensive but the key point is that this cost is only paid once when the debate is finalized.

We can now vote on a debate and have that vote archived on completion and show up in our user profile.

You can get the full source to the end of this article from the 0.0.13 release branch:

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

Notice that we didn’t add a signature to the archived vote? We’ll take a look at that next time along with validation of all our data records.

--

--

Jeremy Orme
Coinmonks

Software engineer. Experimenting with database decentralization