Pandacap: Part 5 - ActivityPub
![[personal profile]](https://www.dreamwidth.org/img/silk/identity/user.png)
![[community profile]](https://www.dreamwidth.org/img/silk/identity/community.png)
Pandacap: Part 5 - ActivityPub
If I'm thinking about how often I use it, the ActivityPub
integration in Pandacap really isn't the most relevant part of
the application to me on a regular basis (that would be the inbox,
and its integration with DeviantArt and Bluesky). It was, however,
my inspiration for building Pandacap in the first place. There
were a few reasons I felt this was important:
- I believe decentralized social media will continue to
be useful, regardless of whether it achieves
mainstream success - maybe not to replace big
social media platforms, but for its own distinct merits. To
hopefully facilitate the adoption of such platforms, I wanted to
ensure that the ability to view and reply to my work would be
available there, and I wanted to know I could follow and reply
to their work as well.
- The ActivityPub server-to-server protocol - though it may be
somewhat broad and vague - was within my ability to implement
(it only uses HTTP requests and is agnostic to the underlying
data model), which gives me a feeling of control over my
interaction with the fediverse.
- Joining an existing server would make me nervous about conforming to unwritten community norms, while using my own Mastodon or Pixelfed instance would increase the cost and saddle me with a tech stack I'm not familiar with. (Pandacap can run on a free web app plan and a database billed only by usage.)
Features
Some of Pandacap's features are exclusive to its ActivityPub
integration.
The most visible example is the favorites page:
The design of the favorites page is the same as the gallery or
inbox, where images get a thumbnail and text posts are just
full-width text (unlike in the inbox, text posts are not
truncated). When a post is added to the favorites page, this is
sent to the original post's server as a Like (and removing from
the favorites page sends a corresponding Undo).
The logged-in user can add or remove a favorite from the "view
remote post" page:
(This page is only available when logged in; public visitors will
get sent to the post on its original server instead.)
Pandacap does not store other users' ActivityPub posts (unless
you add them as favorites); instead, it will pull the post from
the remote server when this page is loaded - something that's only
really feasible for a single-user application. It shows the full
title (if any), text, and any images, and allows the logged-in
user to send a reply. It also shows the post's ActivityPub
addressing.
Replies from others to your own posts are - if you're logged in -
shown on the post's page. (In line with Pandacap's philosophy that
only your own data - or data you put there intentionally - should
be public, these replies are hidden in the public UI.) When
replies are shown, they are also nested, to the extent that
Pandacap's database knows about them. Other user's replies to your
UserPosts or AddressedPosts are stored using
the type RemoteActivityPubReply.
Outgoing replies are stored internally as an AddressedPost
(as opposed to artwork, journals, and status updates, which all
use UserPost). An AddressedPost does not show
up in the public outbox or the UI, but is accessible from its URI
- these are essentially unlisted public ActivityPub posts, and are
used for replies and for posts to Lemmy communities.
Replies to your posts also show up in the notifications
page.
Finally, there's the "communites" feature. Next to "following"
and "favorites", the user's public profile shows a list of Lemmy
communities:
Pandacap does not "follow" these Lemmy communities (it seems
quite easy for things to break, and there's really no reason
Pandacap needs to have a local cache of a Lemmy community's
posts). If a public user clicks one of these links, they get taken
to the Lemmy web UI. But if the Pandacap user is logged in and
clicks through, they'll see the community inside Pandacap:
The "create new post" link under the community title brings up a
dedicated page that asks for a title and post body, and creates an
AddressedPost sent to the Lemmy community.
Pandacap can also fetch and show individual posts:
Both of these pages use the Lemmy
API, instead of interfacing with Lemmy over ActivityPub. But
the "reply" link uses ActivityPub to fetch that post, then sends
you to the same "view remote post" page shown above.
Federation
Pandacap presents a single actor object (at the root URL, with an
Accept header that prefers application/activity+json
over HTML). The actor also exposes:
- a single inbox URL (no shared inbox)
- a paginated outbox (whose underlying implementation is shared with the "all" Atom/RSS feed)
- a followers list stub (count only, no actor IDs are provided)
- an unpaginated following list (including actor IDs)
- a paginated "liked" collection (whose underlying implementation is shared with the Favorites page)
Pandacap artwork posts, status updates, community posts, and
replies are federated as type Note, while journal
entries are federated as type Article. However, artwork
posts (and, optionally, community posts) include a name
field for the post title, which will be ignored by Mastodon.
As of this writing, artwork updates, journal entries, and status
updates are converted to ActivityStreams as follows (where pair
denotes a key followed by a value, and id is the
ActivityPub ID and public URL of the post):
pair "id" id
pair "url" id
pair "type" (if post.Type = PostType.JournalEntry then "Article" else "Note")
if not (isNull post.Title) then
pair "name" post.Title
if not (isNull post.Html) then
pair "content" post.Html
pair "attributedTo" actor_id
pair "tag" [
for tag in post.Tags do dict [
pair "type" "Hashtag"
pair "name" $"#{tag}"
pair "href" $"https://{appInfo.ApplicationHostname}/Profile/Search?q=%%23{Uri.EscapeDataString(tag)}"
]
]
pair "published" post.PublishedTime
pair "to" "https://www.w3.org/ns/activitystreams#Public"
pair "cc" [followers_collection_id]
if post.Images.Count > 0 then
pair "attachment" [
for image in post.Images do
dict [
pair "type" "Image"
pair "url" (mapper.GetImageUrl(post, image.Blob))
pair "mediaType" image.Blob.ContentType
if not (String.IsNullOrEmpty(image.AltText)) then
pair "name" image.AltText
if not (isNull image.FocalPoint) then
pair "focalPoint" [image.FocalPoint.Horizontal; image.FocalPoint.Vertical]
]
]
Community posts (for Lemmy) and replies are converted as follows
(where audience is the ID / URL of the Lemmy community,
if any):
let isReply = not (isNull post.InReplyTo)
let communities = Option.toList post.Audience
let addressing = {|
To = [
"https://www.w3.org/ns/activitystreams#Public"
if not isReply then
yield! communities
]
Cc = [
yield! post.Users
if isReply then
yield! communities
]
|}
pair "id" id
pair "url" id
pair "type" "Note"
if not (isNull post.Title) then
pair "name" post.Title
pair "content" post.HtmlContent
pair "inReplyTo" post.InReplyTo
pair "attributedTo" actor_id
pair "published" post.PublishedTime
pair "to" post.Addressing.To
pair "cc" post.Addressing.Cc
match post.Audience with
| Some id -> pair "audience" id
| _ -> ()
The inbox performs the following steps:
- Expand the content using JSON-LD (which may require a remote
fetch, if a context is used which is not yet cached)
- If this fails, replace the JSON-LD context with https://www.w3.org/ns/activitystreams and https://w3id.org/security/v1 and try again
- Retrieve the actor mentioned in the activity, and verify the request against that actor's public key
- Process the activity based on its type
The following activity types are supported:
- Follow
- Like, Dislike, Flag, Listen, Read, View, Announce (against a
post created by the Pandacap actor)
- Undo (for any of the above)
- Accept, Reject (for Follow)
- Create (for a post that is a reply to one of the Pandacap actor's posts, is addressed to the Pandacap actor, or is from a followed user and is not addressed to any specific user)
- Update (for a follow, follower, or reply)
- Delete (for an inbox post, favorite, or reply)
Outgoing activities are sent every 10 minutes, and are used for:
- Accepting follow requests (sent to the follower)
- Creating top-level posts to Lemmy communities (sent to the community)
- Creating or deleting replies (sent to all addressed users, and to the community, if any)
- Adding or removing favorites (sent to the creator of the favorited post)
- Following and unfollowing users (sent to the user)
- Uploading a new avatar (sent to all followers and all users you follow)
- Creating artwork posts, journal entries, or status updates (sent to all followers, except for Bridgy Fed, which is disabled in my deployment because I now crosspost to Bluesky manually)
- Deleting artwork posts, journal entries, or status updates
(sent to all followers and all users you follow)