Building “Untappd for Coffee” with Sharkey & ActivityPub
Since 2018 I’ve been using the app Untappd to keep track of beers that I drink. When I’m at a restaurant I can take a photo, give it a rating, and file it away for later. Over six years I’ve managed to build up a large corpus of flavor preferences and the app makes it easy to recall past beers.
I like the app for these reasons — that it allows me to rate and track a very particular niche so that I can compare them easily. But I do consume much more than beer. In particular, I want to become a more informed connoisseur of coffee. As there is no coffee check-in app that I’m satisfied with, I’ve decided to build my own.
I tried to build something like this a few years ago but it didn’t really work that well. While I’m able to build a database and a web form, a lot of value of Untappd comes from the social network aspect. So this year, as I resurrect this project, I wanted to try to make sure that last part worked well. As I’m not an expert on building social networks, I wanted to find a good open-source project I could start with.
Today, a lot of open-source development in social media is based around ActivityPub: a standard for building and connecting federated social services together. You might’ve heard of Mastodon, but there are many other experimental projects.
I didn’t need anything very specialized, just a microblogging site that supported ActivityPub and multiple users. That means that even if you aren’t using my coffee app, you could still follow my updates and interact with my checkins. I think that is a key part of the system which can help the app grow organically, since individuals could be curious by what I’m sharing. Untappd used to support automatic sharing to Twitter, although changes to Twitter’s API disabled that feature. In a federated system, those kinds of API changes would be less likely to impact me.
Friendica
I started out with Friendica, an active federated social network platform with a bunch of useful features. Having a project that is actively maintained was important to me. While I tried to read a bit about ActivityPub, I’d much rather use someone else’s implementation and just hack away on my coffee part alone. Friendica is built using MySQL and PHP, and thankfully came with a Docker container to make setup easier.
However, I never got Friendica to work correctly. I couldn’t get it set up because the website container never talked to the database container. Docker is hard, and it would be nice if AI code tools could fix that instead of writing my HTML a little faster.
After spending a few days banging my head on the keyboard, I pivoted.
Sharkey
I had looked into Misskey at the start as it did look to fit all the critiera I was looking for, but the documentation wasn’t great. Half of it is just written in Japanese, making it a lot harder just to get started. But thankfully there are forks of Misskey with more English-speaking developers. Some of them are FireFish and Sharkey.
I was able to get started quickly using a Docker setup which runs on localhost:3000
and it actually works! The setup is in three parts: the Postgres Database, a Redis server, and the Sharkey host. I noticed a lot of small details and customizations that the development teams built over the years, and it made me more confident that this was the right base for my project.
So I made a fork of the project though I didn’t like how they just recently moved away from GitHub. Their new destination, on activitypub.software, seems to have a certificate error which means I couldn’t download the code through the git CLI. I hope they fix that in the future.
Sharkey is written in TypeScript and Vue, which are technologies I’m personally more familiar with. That was also a good sign that I’d be able to dive into the code quickly.
Building Changes
I started with modifying the English text in locales/
. I didn’t want my app to be called Sharkey. I thought Beantown would be a better name. I also wanted to rename references from “Note” to “Checkin” to better reflect app intent.
Whenever I changed locales, I had to run pnpm build-assets
to change them.
Although the documentation for Sharkey is good, and in English, it’s not complete. After I get started, I don’t really have a lot of guidance on what to do next, so I hope this blogpost will provide some interesting guidance for other developers.
Making Changes
Rather than having to build a bunch of new features, I was happier about turning certain features off. In store.ts
you can change the default navigation menu. The options for this menu all come from navbar.ts
.
menu: {
where: 'deviceAccount',
default: [
'announcements',
'search',
'notifications',
'followRequests',
'profile',
'-',
'achievements',
'favorites',
'gallery',
],
},
There are also UI components that I can modify. In navbar.vue
I am able to remove the “More Actions” option at the bottom of the navbar. I want my client app to be very focused on coffee. I also removed widgets by updating the “Universal” theme. Technically, users can find and use other themes which would give them back widgets. But I don’t intend to add any coffee-based widgets to my app. If I do, I’m glad that Sharkey has that as a platform feature. I also wanted to remove certain tabs from timeline.vue
that I didn’t intend to use.
Creating Coffee Input
When you create a new post, you open up MkPostForm.vue
. After assembling a large postData
object, it sends it to the notes/create
API endpoint. From create.ts
in the backend, it then calls the NoteCreateService.ts
to insert a MiNote
into the database.
<div :class="[$style.textOuter, { [$style.withCw]: useCw }]">
<input v-show="!reply && !renote" ref="coffeesInputEl" v-model="checkinCoffee" :class="$style.hashtags" placeholder="Coffee Name" list="coffees" @input="updateBranding" />
<div>
<img :src="coffeeBrandLogo" style="width:24px; height:24px; vertical-align: sub;"/>
<span>{{ coffeeBrandSource }}</span>:
<span>{{ coffeeBrandClass }}</span>
</div>
<input v-show="!reply && !renote" ref="ratingsInputEl" v-model="checkinRating" :class="$style.hashtags" placeholder="Score" type="range" min="0.25" max="5" step="0.25" style="display: inline-block; width: calc(100% - 4em);"/>
<span style="display:inline-block; vertical-align:top">
{{ checkinRating }}
</span>
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
Served
<select v-show="!reply && !renote" v-model="checkinServing">
<option v-for="item in serving">{{ item }}</option>
</select>
at
<input v-show="!reply && !renote" type="search" placeholder="Location" v-model="checkinLocation" />
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
</div>
Then when the MiNote
objects are pulled as part of the timeline, the data appears in an MkNote
and an MkNoteDetailed
if you click on it.
@Column('float', {
default: 0,
comment: 'Checkin rating'
})
public checkinRating: number | null;
@Column('varchar', {
length: 256, nullable: true,
comment: 'Checkin label'
})
public checkinLabel: string | null;
@Column('varchar', {
length: 1024, nullable: true,
comment: 'Logo URL of coffee'
})
public checkinLogo: string | null;
@Column('varchar', {
length: 256, nullable: true,
comment: 'Manufacturer coffee'
})
public checkinSource: string | null;
@Column('varchar', {
length: 64, nullable: true,
comment: 'Classification'
})
public checkinClass: string | null;
@Column('varchar', {
length: 64, nullable: true,
comment: 'Serving style'
})
public checkinServing: string | null;
@Column('varchar', {
length: 256, nullable: true,
comment: 'Location of checkin'
})
public checkinLocation: string | null;
I need to update the Postgres database. Sharkey uses a tool called TypeORM to manage database operations in TypeScript. In theory its CLI is supposed to help you create database migrations, but I couldn’t get the tool to generate automatic configurations. The TypeORM documentation is lacking in examples which could provide more context and different scenarios.
Thankfully I didn’t need to make big changes, just add a few new optional columns which may be connected to a post:
await queryRunner.query(`ALTER TABLE "note" ADD "checkinRating" float NULL`)
await queryRunner.query(`ALTER TABLE "note" ADD "checkinLabel" character varying(256)`)
await queryRunner.query(`ALTER TABLE "note" ADD "checkinLogo" character varying(1024)`)
await queryRunner.query(`ALTER TABLE "note" ADD "checkinSource" character varying(256)`)
await queryRunner.query(`ALTER TABLE "note" ADD "checkinClass" character varying(64)`)
await queryRunner.query(`ALTER TABLE "note" ADD "checkinServing" character varying(64)`)
await queryRunner.query(`ALTER TABLE "note" ADD "checkinLocation" character varying(256)`)
I’ve added several new columns which encompass all parts of a coffee checkin and will make it easy in the future to build custom sort and filter operations.
Then when I run the migration command, my database was updated:
$ pnpm migrate
query: START TRANSACTION
query: ALTER TABLE "note" ADD "checkinRating" float NULL
query: ALTER TABLE "note" ADD "checkinLabel" character varying(256)
query: ALTER TABLE "note" ADD "checkinLogo" character varying(1024)
query: ALTER TABLE "note" ADD "checkinSource" character varying(256)
query: ALTER TABLE "note" ADD "checkinClass" character varying(64)
query: ALTER TABLE "note" ADD "checkinServing" character varying(64)
query: ALTER TABLE "note" ADD "checkinLocation" character varying(256)
query: INSERT INTO "migrations"("timestamp", "name") VALUES ($1, $2) -- PARAMETERS: [1709081561725,"Beantown1709081561725"]
Migration Beantown1709081561725 has been executed successfully.
query: COMMIT
As an aside, I screwed up the database at one point and the configuration was corrupted. I was unhappy about that. What I had to do was effectively a factory reset of my Postgres DB. To do that, I ran docker-compose down && docker volume rm sharkey_db
. When I recreated the composed Docker container, everything worked fine and my columns were working.
With that out of the way, I was able to update the UI and backend bits to work end-to-end. What you saw is not a mock, but a real screenshot of my app. Everything works on localhost, and that’s awesome.
There’s still a little work to be done. I want to fix some of the UI and the note editing process before I deploy my Docker containers to production. I’m probably a week away from that being complete. Look forward to reading a Part 2 where I write a bit more about my changes.
Once my MVP is launched, I’ll keep working on it. I’ve got many other features I want to add. I’ll have to put on a pot of coffee. It’ll be measurably the best coffee I’ve ever had.