Lemmy 1.0's multi-communities and PieFed's feeds (`type: Feed`) resolve as accounts: the feed's `following` is kept
once a day with its counts, each community in it is read in turn, and `/api/v1/accounts/:id/following` lists them.
Lemmy's feed carries the instance's key and its text in `description`; both are taken as they are. Following a feed
answers 422 until the owner decides how a feed is followed.
A post moved to another community (PieFed's Move{object: post, origin, target}), relayed by the community it leaves,
goes there with its thread; a move into a community we host is not taken.
Checked in the pasture: a Lemmy 1.0 feed and a PieFed feed list their communities (ours among them); a PieFed post moved
from its web code arrives moved, with its comment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
62189bf11d
commit
3b3f1f6b93
10 files changed
+226
-25
No files matched your search
+6
-1
@@ -167,7 +167,12 @@ A group is either a **community** or a **circle**.
|
||||
and from a community elsewhere once the post's origin answers it gone. A lock sent as it is (`Lock`, Mbin) is taken
|
||||
from the post's own server only. A lock (`Announce{Lock}`, or `commentsEnabled: false` on the post) refuses replies,
|
||||
ours included, until `Undo{Lock}`. A ban of a persona (`Announce{Block}` with the community as `target`) shows as
|
||||
`blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`.
|
||||
`blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`. A post moved to
|
||||
another community (PieFed's `Announce{Move{object: post, origin, target}}`, relayed by the community it leaves) goes
|
||||
there with its thread; a move into a community this server hosts is not taken.
|
||||
- **Feeds** (Lemmy 1.0's multi-communities and PieFed's feeds, `type: Feed`) are read as accounts whose following is
|
||||
their communities: the feed's `following` collection, read once a day with its counts, and each community in it read in
|
||||
turn. A feed cannot be followed from here yet; its communities can.
|
||||
|
||||
## Activities
|
||||
|
||||
|
||||
@@ -412,6 +412,48 @@ namespace PrivaPub.Tests.Federation
|
||||
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).FavouritesCount);
|
||||
}
|
||||
|
||||
// PieFed moves a post to another community, and the community it leaves relays the Move: the post's thread goes with it
|
||||
[Fact]
|
||||
public async Task A_post_moved_to_another_community_takes_its_thread_along()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
|
||||
var other = new RemoteActor(_harness.Peer, "dogs", type: "Group");
|
||||
var poster = new RemoteActor(_harness.Peer, "poster");
|
||||
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
|
||||
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
|
||||
async Task<Post> Announced(JsonObject note)
|
||||
{
|
||||
note["audience"] = community.Id;
|
||||
_harness.Peer.Serve(new Uri(IdOf(note)).AbsolutePath, note.ToJsonString());
|
||||
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, note)));
|
||||
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
|
||||
}
|
||||
var page = PublicNote(poster, "<p>misplaced</p>", community.Id);
|
||||
page["type"] = "Page";
|
||||
page["name"] = "misplaced";
|
||||
var post = await Announced(page);
|
||||
var comment = PublicNote(poster, "<p>a comment</p>", community.Id);
|
||||
comment["inReplyTo"] = IdOf(page);
|
||||
var answer = await Announced(comment);
|
||||
var move = new JsonObject
|
||||
{
|
||||
["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = community.Id, ["target"] = other.Id
|
||||
};
|
||||
|
||||
// only the community it leaves speaks for it
|
||||
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", new JsonObject
|
||||
{
|
||||
["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = other.Id, ["target"] = community.Id
|
||||
}));
|
||||
Assert.Equal(("dropped", "unsupported"), (Processed("Announce").Outcome, Processed("Announce").Reason));
|
||||
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", move));
|
||||
Assert.Equal(("accepted", "moved"), (Processed("Announce").Outcome, Processed("Announce").Reason));
|
||||
Assert.Equal(other.Id, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).AudienceURI);
|
||||
Assert.Equal(other.Id, (await DB.Default.Find<Post>().OneAsync(answer.ID, token)).AudienceURI);
|
||||
}
|
||||
|
||||
// Lemmy's moderators lock a community's post and ban members, and the community relays both inside its Announce
|
||||
// (G-0006): a locked post takes no reply, a banned persona posts nothing there, until the Undo
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Http
|
||||
{
|
||||
// Lemmy 1.0's multi-communities and PieFed's feeds are `Feed` actors whose `following` is their communities
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class MastodonFeedTests : IAsyncLifetime
|
||||
{
|
||||
PrivaPubHost _host;
|
||||
Peer _peer;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_host = await PrivaPubHost.Shared();
|
||||
_peer = await Peer.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_peer != default)
|
||||
await _peer.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_feed_shows_as_an_account_whose_following_is_its_communities()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var reader = await _host.Mastodon("feeder");
|
||||
var cats = new RemoteActor(_peer, "cats", type: "Group");
|
||||
var dogs = new RemoteActor(_peer, "dogs", type: "Group");
|
||||
var name = $"feed{Guid.NewGuid():N}"[..12];
|
||||
var feed = $"{_peer.A}/m/{name}";
|
||||
// as Lemmy serves one: the instance's inbox and key, its text in `description`
|
||||
_peer.Serve($"/m/{name}", new JsonObject
|
||||
{
|
||||
["@context"] = new JsonArray("https://join-lemmy.org/context.json", "https://www.w3.org/ns/activitystreams"),
|
||||
["type"] = "Feed", ["id"] = feed, ["inbox"] = $"{_peer.A}/inbox",
|
||||
["publicKey"] = new JsonObject { ["id"] = $"{_peer.A}/#main-key", ["owner"] = $"{_peer.A}/", ["publicKeyPem"] = "-----BEGIN PUBLIC KEY-----\n-----END PUBLIC KEY-----\n" },
|
||||
["following"] = $"{feed}/following", ["preferredUsername"] = name, ["name"] = "Pets", ["description"] = "every pet",
|
||||
["attributedTo"] = $"{_peer.A}/users/owner"
|
||||
}.ToJsonString());
|
||||
_peer.Serve($"/m/{name}/following", new JsonObject
|
||||
{
|
||||
["type"] = "Collection", ["id"] = $"{feed}/following", ["totalItems"] = 2, ["items"] = new JsonArray(cats.Id, dogs.Id)
|
||||
}.ToJsonString());
|
||||
|
||||
var account = await _host.Get<IRemoteActorService>().GetActor(feed, refresh: true, token);
|
||||
Assert.NotNull(account);
|
||||
await _host.Run(j => j.Kind == JobKind.CountAccount && j.Payload == feed, token);
|
||||
|
||||
var shown = (await reader.Client.Get($"/api/v1/accounts/{account.ID}")).Ok().Body;
|
||||
Assert.Equal(("Pets", 2), (shown.Text("display_name"), shown.Number("following_count")));
|
||||
Assert.Contains("every pet", shown.Text("note"));
|
||||
var communities = (await reader.Client.Get($"/api/v1/accounts/{account.ID}/following")).Ok().Array;
|
||||
Assert.Equal(new[] { cats.Id, dogs.Id }, communities.Select(c => c.Text("uri")));
|
||||
Assert.All(communities, c => Assert.True(c!["group"]!.GetValue<bool>()));
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await reader.Client.Post($"/api/v1/accounts/{account.ID}/follow")).Status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,7 +253,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpGet("/api/v1/accounts/{id}/following"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Following(string id, CancellationToken token)
|
||||
{
|
||||
var (local, _) = await Find(id, token);
|
||||
var (local, remote) = await Find(id, token);
|
||||
// a feed's following is what it is: its communities, as its server lists them
|
||||
if (remote is { AvatarType: AvatarType.Feed })
|
||||
return Json(await AccountsFor(remote.FeedCommunities.Take(Limit(40, 80)), token));
|
||||
if (local == default || local.Id != MyId)
|
||||
return Json(Array.Empty<Account>());
|
||||
var following = await Page.From(Params, Limit(40, 80))
|
||||
@@ -269,6 +272,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
if (remote is { AvatarType: AvatarType.Feed })
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "A feed is followed through its communities");
|
||||
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token);
|
||||
return following == default ? Error(StatusCodes.Status403Forbidden, "This action is not allowed") : Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
@@ -12,18 +12,23 @@ namespace PrivaPub.Federation.Actors
|
||||
// A remote account's follower, following and post counts, as its own server publishes them (the totalItems of its
|
||||
// followers, following and outbox collections), and the posts it features. Read when the account is fetched, at most
|
||||
// once a day, never when someone looks at it; a hidden or missing collection leaves its count unknown. Mastodon and
|
||||
// GoToSocial do the same.
|
||||
// GoToSocial do the same. A feed's following is its communities: they are kept, and read in turn, so its list shows.
|
||||
public class AccountCountsJob : IJobHandler
|
||||
{
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IFeaturedPosts _featured;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default)
|
||||
public const int MaxFeedCommunities = 50;
|
||||
|
||||
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default,
|
||||
ILocalActorService localActors = default)
|
||||
{
|
||||
_remoteActors = remoteActors;
|
||||
_dbEntities = dbEntities;
|
||||
_featured = featured;
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
public JobKind Kind => JobKind.CountAccount;
|
||||
@@ -38,28 +43,52 @@ namespace PrivaPub.Federation.Actors
|
||||
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == job.Payload && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (actor == default)
|
||||
return JobOutcome.Done;
|
||||
await DB.Default.Update<ForeignAvatar>().MatchID(actor.ID)
|
||||
.Modify(a => a.FollowersCount, await Total(actor, actor.FollowersURL, token))
|
||||
.Modify(a => a.FollowingCount, await Total(actor, actor.FollowingURL, token))
|
||||
.Modify(a => a.StatusesCount, await Total(actor, actor.OutboxURL, token))
|
||||
.Modify(a => a.CountedAt, DateTime.UtcNow)
|
||||
.ExecuteAsync(token);
|
||||
var feed = actor.AvatarType == AvatarType.Feed;
|
||||
var (following, communities) = await Read(actor, actor.FollowingURL, feed, token);
|
||||
var update = DB.Default.Update<ForeignAvatar>().MatchID(actor.ID)
|
||||
.Modify(a => a.FollowersCount, (await Read(actor, actor.FollowersURL, false, token)).Total)
|
||||
.Modify(a => a.FollowingCount, following)
|
||||
.Modify(a => a.StatusesCount, (await Read(actor, actor.OutboxURL, false, token)).Total)
|
||||
.Modify(a => a.CountedAt, DateTime.UtcNow);
|
||||
if (feed)
|
||||
update = update.Modify(a => a.FeedCommunities, communities);
|
||||
await update.ExecuteAsync(token);
|
||||
if (feed)
|
||||
foreach (var community in communities)
|
||||
if (_localActors == default || !community.StartsWith(_localActors.BaseAddress + "/", StringComparison.Ordinal))
|
||||
await _remoteActors.GetActor(community, refresh: false, token);
|
||||
if (_featured != default)
|
||||
await _featured.Sync(actor, token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
|
||||
// only from the actor's own server, as everything we believe about it
|
||||
async Task<int?> Total(ForeignAvatar actor, string collection, CancellationToken token)
|
||||
// only from the actor's own server, as everything we believe about it: its total, and the ids it lists when asked
|
||||
// (inline, or on its first page)
|
||||
async Task<(int? Total, List<string> Items)> Read(ForeignAvatar actor, string collection, bool items, CancellationToken token)
|
||||
{
|
||||
var listed = new List<string>();
|
||||
if (string.IsNullOrEmpty(collection) || !Origin.Same(collection, actor.ActorURI))
|
||||
return default;
|
||||
return (default, listed);
|
||||
using var scope = HttpScope.For("collection");
|
||||
using var fetched = await _remoteActors.FetchObject(collection, token);
|
||||
return fetched != default && fetched.Root.ValueKind == System.Text.Json.JsonValueKind.Object
|
||||
&& fetched.Root.TryGetProperty("totalItems", out var total) && total.TryGetInt32(out var count) && count >= 0
|
||||
? count
|
||||
: default;
|
||||
if (fetched == default || fetched.Root.ValueKind != System.Text.Json.JsonValueKind.Object)
|
||||
return (default, listed);
|
||||
int? total = fetched.Root.TryGetProperty("totalItems", out var counted) && counted.TryGetInt32(out var count) && count >= 0 ? count : default;
|
||||
if (!items)
|
||||
return (total, listed);
|
||||
var document = System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText());
|
||||
var page = document["orderedItems"] ?? document["items"] ?? document["first"]?["orderedItems"] ?? document["first"]?["items"];
|
||||
if (page == default && ActivityJson.Id(document["first"]) is { } first && Origin.Same(first, actor.ActorURI))
|
||||
{
|
||||
using var next = await _remoteActors.FetchObject(first, token);
|
||||
var nextPage = next == default ? default : System.Text.Json.Nodes.JsonNode.Parse(next.Root.GetRawText());
|
||||
page = nextPage?["orderedItems"] ?? nextPage?["items"];
|
||||
}
|
||||
if (page is System.Text.Json.Nodes.JsonArray array)
|
||||
listed = array.Select(ActivityJson.Id)
|
||||
.Where(id => Uri.TryCreate(id, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http")
|
||||
.Distinct(StringComparer.Ordinal).Take(MaxFeedCommunities).ToList();
|
||||
return (total, listed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ namespace PrivaPub.Federation.Actors
|
||||
|
||||
public sealed class ActorDocument
|
||||
{
|
||||
static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization" };
|
||||
static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization", "Feed" };
|
||||
|
||||
public string Id { get; init; }
|
||||
public string Type { get; init; }
|
||||
@@ -74,7 +74,8 @@ namespace PrivaPub.Federation.Actors
|
||||
Features = ActorFeatures.Detect(root),
|
||||
PreferredUsername = RemoteActorService.Text(root, "preferredUsername"),
|
||||
Name = RemoteActorService.Text(root, "name"),
|
||||
Summary = RemoteActorService.Text(root, "summary"),
|
||||
// (Lemmy's feed keeps its text in `description`)
|
||||
Summary = RemoteActorService.Text(root, "summary") ?? (type == "Feed" ? RemoteActorService.Text(root, "description") : default),
|
||||
Url = RemoteActorService.Text(root, "url") ?? id,
|
||||
Inbox = Origin.Same(inbox, id) ? inbox : default,
|
||||
Outbox = RemoteActorService.Text(root, "outbox"),
|
||||
|
||||
@@ -59,7 +59,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var inner = activity["object"];
|
||||
if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block" or "Lock")
|
||||
if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block" or "Lock" or "Move")
|
||||
{
|
||||
await GroupActivity(inner, actor, token);
|
||||
return;
|
||||
@@ -242,6 +242,9 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
case "Lock" when objectUri != default:
|
||||
await Lock(objectUri, group, true, token);
|
||||
break;
|
||||
case "Move" when objectUri != default && Id(inner["origin"]) == group.ActorURI:
|
||||
await Move(objectUri, Id(inner["target"]), group, token);
|
||||
break;
|
||||
case "Undo" when Value(inner["object"], "type") == "Lock":
|
||||
await Lock(Id(inner["object"]?["object"]), group, false, token);
|
||||
break;
|
||||
@@ -305,6 +308,42 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
Arrival.Accept(locked ? "locked" : "unlocked");
|
||||
}
|
||||
|
||||
// A post moved to another community (PieFed's Move{object: post, origin, target}), relayed by the community it leaves,
|
||||
// which vouches for what is done to its own posts as for a lock; its thread goes with it. A move into a community we
|
||||
// host is not taken: it would be ours to announce.
|
||||
async Task Move(string objectUri, string targetUri, ForeignAvatar group, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue)
|
||||
.ExecuteFirstAsync(token);
|
||||
if (post == default)
|
||||
{
|
||||
Arrival.Drop("unknown-object");
|
||||
return;
|
||||
}
|
||||
Arrival.About(visibility: post.Visibility, created: post.CreationDate);
|
||||
var target = targetUri == default || targetUri == group.ActorURI || await _localActors.FindByUri(targetUri, token) != default
|
||||
? default
|
||||
: await _remoteActors.GetActor(targetUri, refresh: false, token);
|
||||
if (target is not { AvatarType: AvatarType.Group })
|
||||
{
|
||||
Arrival.Drop("unknown-target");
|
||||
return;
|
||||
}
|
||||
var thread = new List<string> { post.ID };
|
||||
var level = thread.ToList();
|
||||
while (level.Count > 0 && thread.Count < MaxMoved)
|
||||
{
|
||||
var parents = level;
|
||||
level = (await _dbEntities.Posts.Match(p => parents.Contains(p.AnsweringToPostId) && p.AudienceURI == group.ActorURI)
|
||||
.Project(p => new PostEntity { ID = p.ID }).ExecuteAsync(token)).Select(p => p.ID).ToList();
|
||||
thread.AddRange(level);
|
||||
}
|
||||
await DB.Default.Update<PostEntity>().Match(p => thread.Contains(p.ID)).Modify(p => p.AudienceURI, target.ActorURI).ExecuteAsync(token);
|
||||
Arrival.Accept("moved");
|
||||
}
|
||||
|
||||
const int MaxMoved = 1000;
|
||||
|
||||
// A community bans one of our personas (Lemmy's Block with the community as its target): kept as the community
|
||||
// blocking the persona, so its relationship says blocked_by and nothing of the persona's is posted there until the
|
||||
// Undo. A ban of someone from another server is no business of ours.
|
||||
|
||||
@@ -78,6 +78,7 @@ namespace PrivaPub.Models.User
|
||||
public string OutboxURL { get; set; }
|
||||
public string FeaturedURL { get; set; }//featured: the posts it pins
|
||||
public List<PrivaPub.Models.Post.PostFlair> Flairs { get; set; } = new();//a community's labels for its posts
|
||||
public List<string> FeedCommunities { get; set; } = new();//a feed's communities, its `following` (Lemmy's multi-communities, PieFed's feeds)
|
||||
public List<string> SearchableBy { get; set; }//FEP-268d, when the account states it: outranks IsIndexable
|
||||
public string ModeratorsURL { get; set; }//a community's attributedTo (Lemmy, PieFed, Mbin): the collection of its moderators
|
||||
public List<AssertionKey> AssertionKeys { get; set; } = new();//its Ed25519 keys (FEP-521a), which prove what it sends (FEP-8b32)
|
||||
@@ -152,7 +153,8 @@ namespace PrivaPub.Models.User
|
||||
Group,
|
||||
Organization,
|
||||
Person,
|
||||
Service
|
||||
Service,
|
||||
Feed
|
||||
}
|
||||
|
||||
public enum AvatarAccountState
|
||||
|
||||
+11
-3
@@ -492,7 +492,11 @@ on a Page: pins live in `featured`, locks in `Lock`.
|
||||
- **Private messages:** `ChatMessage` on 0.19; a single-recipient `Note` on 1.0.
|
||||
- **Context:** 1.0's `context` collection is unpaged and **every comment's own context URL returns the whole post
|
||||
thread**. Group threads by the root post, never by comparing `context` strings.
|
||||
- **Feeds:** multi-communities are 1.0's `type: Feed` actors.
|
||||
- **Feeds:** multi-communities are 1.0's `type: Feed` actors at `/m/<name>`, with no WebFinger handle: the instance's
|
||||
shared inbox and the **instance's key** (`owner` is the instance, not the feed), text in `description` (not `summary`),
|
||||
the creator in `attributedTo`, and `following`, an unpaged `Collection` of the community ids with `totalItems`.
|
||||
Following one stays on the follower's server, which subscribes to each community itself (checked 2026-10-07 on
|
||||
1.0.0-beta.2).
|
||||
|
||||
**Expects**
|
||||
- **Fetched documents:** Content-Type exactly one of `activity+json`, `activity+json; charset=utf-8`, or `ld+json` with
|
||||
@@ -595,8 +599,12 @@ What it showed:
|
||||
- **polls in communities** (`Question` with `votersCount`), and Mobilizon-style `Event`s;
|
||||
- flairs in two dialects;
|
||||
- comments: `repliesEnabled` (comment lock), `answer`, plus custom `ChooseAnswer` and `PollVote` activities;
|
||||
- `Move{object: post}` between communities;
|
||||
- `Feed` actors;
|
||||
- `Move{object: post, origin, target}` between communities, by the post's author or a moderator of the origin, relayed
|
||||
as the origin community's `Announce` (only from the web UI; its API has no move);
|
||||
- `Feed` actors at `/f/<name>` with their own key, inbox, `followers`, `moderators` (the owner) and `following` (the
|
||||
communities, a `Collection` with `totalItems`); PieFed's handle is `~name@host`. Following one is a `Follow` to the
|
||||
feed, after which its owner's server sends `Add`/`Remove` of communities, signed by the feed; each member also
|
||||
subscribes to every community as themselves (`feed_auto_follow`). Its API creates feeds (`POST /api/alpha/feed`);
|
||||
- emoji Likes and `EmojiReact` count as upvotes.
|
||||
- **How PieFed formats what it sends us:**
|
||||
- It **chooses the format by our NodeInfo software name**, so keep that accurate.
|
||||
|
||||
+4
-1
@@ -696,7 +696,10 @@ it, raw where it doesn't.
|
||||
server or its moderators collection, kept as `ForeignAvatar.ModeratorsURL`; a resolution is kept on our report);
|
||||
- votes in and out; link posts; flairs (**done 2026-10-07**: Lemmy 1.0's `CommunityPostTag` and PieFed's
|
||||
`lemmy:CommunityTag`, on posts and communities, described from the community's list, as `privapub.flairs`); `Feed`
|
||||
actors; community polls; post `Move`;
|
||||
actors (**read 2026-10-07**: Lemmy 1.0's and PieFed's feeds resolve as accounts whose `/following` is their
|
||||
communities, kept from the feed's `following` once a day; following one is refused for now, **owner decision
|
||||
pending** on how); community polls; post `Move` (**done 2026-10-07**: PieFed's, relayed by the community the post
|
||||
leaves, moves the post and its thread; checked live);
|
||||
- the outbound shape Lemmy requires;
|
||||
- communities we host announce to the author's own instance too: **done 2026-10-06** (its server's shared inbox,
|
||||
whether anyone there follows the community or not: Lemmy keeps its user's post pending until it is announced back);
|
||||
|
||||
Reference in new issue
Block a user