Miguel Piedrafita

ENS

miguel.build

Decentralized Comments for Mirror

Miguel Piedrafita

0xE340…DCb39D

You can now comment on my Mirror entries! Here’s how I implemented it in a decentralized way.


For a while now I’ve been maintaining a custom Mirror client. This allows me to use a custom domain (m1guelpf.blog instead of miguel.mirror.xyz), provide an RSS feed and retain some control over the design.

So, when I came across The Convo Space, a “conversation protocol” built on top of IPFS & Libp2p, I decided to try my hand at building a decentralized commenting system for the client.

Follow the Conversation

To get started, we need a way of getting the comments for the current entry. TheConvo works with threads and, fortunately, we can make up our own thread IDs. Since Mirror digests are supposed to be unique, we can use those as our thread ID, linking the comments to the entry regardless of the URL it’s displayed at.

With our thread ID figured out, we can use Vercel’s SWR library to fetch comments on page load.

const { data: comments, mutate } = useSWR(
    `https://theconvo.space/api/comments?apikey=CONVO&threadId=${entry.digest}`,
    { revalidateOnFocus: false }
)

This gets us an array of comments we can loop over, including the comment’s contents, timestamp & address/ENS of the author. Since my design also included an avatar field, I tweaked the fetcher function to look at the avatar field of the commenters’ ENS domains, defaulting to a gradient avatar if they hadn’t set one or didn’t have an ENS domain.

const DEFAULT_AVATAR = 'https://cdn.tryshowtime.com/profile_placeholder.jpg'

const getAvatarFromENS = ensName => {
    if (! ensName) return DEFAULT_AVATAR

    return serverWeb3
        .getResolver(ensName)
        .then(resolver => resolver?.getText('avatar') || DEFAULT_AVATAR)
}

const commentFetcher = url =>
	fetch(url)
		.then(res => res.json())
		.then(comments => Promise.all(comments.map(async comment => ({
            ...comment,
            authorAvatar: getAvatarFromENS(comment.authorENS),
        }))))

const { data: comments, mutate } = useSWR(
    `https://theconvo.space/api/comments?apikey=CONVO&threadId=${digest}`,
    commentFetcher,
    { revalidateOnFocus: false }
)

Inviting the Conversation

With a system to read comments in place, we now need a way to post them. TheConvo’s process for this is pretty straightforward: authenticate the user using a personal signature and send an API request to post the comment.

Please Sign Here

To authenticate the users, we need to craft a special signature composed of the user’s wallet address and the current timestamp.

const timestamp = Date.now()
const signerAddress = await web3.getSigner().getAddress()
const signature = await web3.getSigner().signMessage(
    `I allow this site to access my data on The Convo Space using the account ${signerAddress}. Timestamp:${timestamp}`
)

const token = await axios.post(
    'https://theconvo.space/api/auth?apikey=CONVO',
    { signerAddress, signature, timestamp }
).then(res => res.data?.message)

This token will be valid for 1 day, so I wanted to avoid prompting the user for a signature more than once a day. I thought about storing the token on localStorage along with an expiry date but ended up going with a cookie, as you can set those to expire automatically. Instead of calling the Convo API directly, I created an API route to handle login.

import axios from 'axios'
import { serialize } from 'cookie'

const ONE_DAY = 60 * 60 * 24 * 1000

function createCookie(name, data, options = {}) {
	return serialize(name, data, {
		maxAge: ONE_DAY,
		expires: new Date(Date.now() + ONE_DAY * 1000),
		secure: process.env.NODE_ENV === 'production',
		path: '/',
		httpOnly: true,
		sameSite: 'lax',
		...options,
	})
}

export default async ({ method, body: { signerAddress, signature, timestamp } }, res) => {
	if (method != 'POST') return res.status(405).send('Not Found')

	const token = await axios.post(
        'https://theconvo.space/api/auth?apikey=CONVO',
        { signerAddress, signature, timestamp }
    ).then(res => res.data?.message)

	res.setHeader('Set-Cookie', [
        createCookie('convo_token', token),
        createCookie('convo_authed', true, { httpOnly: false })
    ])

	res.status(200).end()
}

Notice how I’m setting two cookies. The first one, convo_token, contains the actual token but is not accessible from JS. A second one, convo_authed, allows us to check if the token exists from the frontend, without exposing it. Here’s our updated code for the front end.

const authenticateConvo = async web3 => {
	const timestamp = Date.now()
	const signerAddress = await web3.getSigner().getAddress()
	const signature = await web3.getSigner().signMessage(
		`I allow this site to access my data on The Convo Space using the account ${signerAddress}. Timestamp:${timestamp}`
	)

	await axios.post('/api/comments/login', {
		signerAddress, signature, timestamp
	})
}

if (!document.cookie.includes('convo_authed')) await authenticateConvo(web3)

POSTing the Comment

Finally, posting the comment is as simple as making a POST request to the /api/comments endpoint. Since the frontend doesn’t have access to the Convo token, I created another API route for this.

import axios from 'axios'

export default async ({ method, headers: { referer }, body: { signerAddress, comment, digest }, cookies: { convo_token } }, res) => {
	if (method != 'POST') return res.status(405).send('Not Found')
	if (!comment || !digest || !signerAddress) return res.status(400).send('Invalid Params')
	if (!convo_token) return res.status(401).send('Unauthorized')

	try {
		await axios.post(
            'https://theconvo.space/api/validateAuth?apikey=CONVO',
            { token: convo_token, signerAddress }
        )
	} catch {
		return res.status(401).send('Unauthorized')
	}

	await axios.post(
        'https://theconvo.space/api/comments?apikey=CONVO',
        { token: convo_token, signerAddress, comment, threadId: digest, url: referer }
    ).then(resp => res.json(resp.data))
}

Back on the front end, we can use SWR’s mutate function to instantly display the comment at the end of our list (while we retry the request to fetch the new data).

const result = await axios.post('/api/comments/post', {
	signerAddress: await web3.getSigner().getAddress(), comment, digest
}).then(res => res.data)

setComment('')
mutate(comments => {
	comments.push({
		...result,
	    authorAvatar: 'https://cdn.tryshowtime.com/profile_placeholder.jpg'
	})
}, true)

That’s all, folks!

Hope you enjoyed the post! The Mirror client I mentioned at the start is open-source, and you can look at the commit that added the comment system here.

You can follow me on Twitter to keep up with what I’m building, or leave a comment below to try the whole thing out!