Find the index and earlier parts of this series here.

We’re almost there! Thanks for sticking with me on this journey. In this final part, we’ll integrate replies and comments into your static website.

Motivation

Integrating replies is quite similar to how we handled subscriptions. Since our posts now exist in the Fediverse, we want to treat them as first-class citizens. This means replies to our posts should appear on our site.

Think of our site as an ActivityPub implementation (with some limitations and that it’s not fully compliant). The key tasks here are:

  1. Handling replies
  2. Displaying replies

Fortunately, we’ve already set up an Inbox endpoint and handled one type of message: Follow. Now we’ll extend this to support Create activities (notes).

Overview

The Inbox handles POST HTTP requests for various actions. These requests may or may not be signed, and you can choose how to respond. There’s no strict minimum for an ActivityPub implementation, as seen with Threads' phased federation rollout. Building on Part 6, we’ll now add support for replies.

Replies: The Flow

Here’s a high-level view of the process (we’ll break it down step-by-step):

  
sequenceDiagram  
    participant Commenter instance
    participant Inbox  
    participant Table-Storage  
    participant Static-Storage  
    Commenter instance->>Inbox: Create activity note (reply) request
    Inbox->>Commenter instance: 200 OK  
    Inbox->>Table-Storage: Save reply URI/ID  
    Inbox->>Static-Storage: Generate replies collection  

In Part 4, we added a replies endpoint for each post, like this:

"replies": {  
  "id": "https://maho.dev/socialweb/replies/a9e885b19fe0a2aceaf7696eb6d4b646",  
  "type": "Collection",  
  "first": {  
    "type": "CollectionPage",  
    "next": "https://maho.dev/socialweb/replies/a9e885b19fe0a2aceaf7696eb6d4b646?page=true",  
    "partOf": "https://maho.dev/socialweb/replies/a9e885b19fe0a2aceaf7696eb6d4b646",  
    "items": []  
  }  
}

The endpoint is predictable because we know the internal ID of the note (a9e885b19fe0a2aceaf7696eb6d4b646). Even if the URL doesn’t exist when the post is created, it’s still referenced in the note.

Note: This collection isn’t truly paginated—it just mimics Mastodon’s structure.

In the last step, we generate the replies collection. It looks like this:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://maho.dev/socialweb/replies/a9e885b19fe0a2aceaf7696eb6d4b646?page=true",
  "partOf": "https://maho.dev/socialweb/replies/a9e885b19fe0a2aceaf7696eb6d4b646",
  "type": "CollectionPage",
  "items": [
    "https://hachyderm.io/users/mapache/statuses/113868758629132410"
  ]
}

The items array holds URIs for replies. To avoid managing deleted or modified statuses, we only store identifiers and fetch the data in real-time.


.NET Implementation

Find the implementation in my repo: Almost Static ActivityPub. Check out these key files:

In RepliesService.cs, the Inbox only processes Create messages originating from our domain:

if (!objectNote!.InReplyTo?.StartsWith(Domain) ?? true) {
    return; // Ignore messages from other domains
}

This filters out irrelevant messages from federated instances. You may want to add other filters?

Displaying Replies

Now that replies are stored, how do we show them on the site? Inspired by other implementations, I fetch comments based on the Mastodon post URL, which in our case is our ActivityPub post URL.

Here’s the logic:

  1. Fetch the replies URL (https://maho.dev/socialweb/replies/bcc93f1e77a9eaa4277b430815352ddd), appending a timestamp to bypass caching.
    • If 404, no replies exist yet.
  2. For each reply, fetch its data (application/activity+json).
  3. Parse the comment and display it.

This is the JavaScript powering the “Load Comments” button:

function loadComments() {
    let commentsWrapper = document.getElementById("comments-wrapper");
    const timestamp = Date.now();

    document.getElementById("load-comment").innerText = "Loading";
    fetch(`https://maho.dev/socialweb/replies/bcc93f1e77a9eaa4277b430815352ddd?timestamp=${timestamp}`)
        .then(response => {
            if (response.ok) return response.json();
            if (response.status === 404) {
                commentsWrapper.innerHTML = "<p>No comments found</p>";
                return Promise.reject("No comments found");
            }
            commentsWrapper.innerHTML = "<p>Error fetching comments.</p>";
            return Promise.reject("Error fetching comments");
        })
        .then(data => Promise.all(
            data.items.map(replyId => fetch(replyId, {
                headers: { 'Content-Type': 'application/activity+json', 'Accept': 'application/activity+json' }
            }).then(response => response.ok ? response.json() : Promise.reject("Error fetching reply")))
        ))
        .then(responses => {
            responses.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
            commentsWrapper.innerHTML = responses.map(status => `<p>${status.content}</p>`).join("");
        })
        .catch(console.error);
}
document.getElementById("load-comment").addEventListener("click", loadComments);

As you can see, nothing out of the world. You can find the full implementation here or just peeking to my page source code.

Final note

With this final piece in place, your static site now integrates seamlessly with the Fediverse by handling replies and comments. By leveraging ActivityPub, you’ve turned a static site into a social participant, capable of engaging with decentralized networks like Mastodon. This implementation not only enhances the interactivity of your site but also keeps it lightweight and maintainable.

If you have a static site on the Fediverse, feel free to share it—I’d love to check it out and follow your journey! Thank you for reading this series!