P7: downvotes go out, and feeds are read through the server's reader
Votes out. A persona's downvote is a Dislike (POST /api/v1/statuses/:id/downvote and /undownvote, the viewer's own as privapub.votes.downvoted). One vote a post: a changed vote is sent as the new vote alone, as Lemmy sends one, since an Undo of the old one beside it could arrive after it and take the new one away; Undo goes only when a vote is taken back. A vote on a post in a remote community goes to the community, which counts it, and to the author only when on another server: one copy a server, since PieFed drops a second copy of an activity it has just seen. Mbin keeps a favourite apart from a vote, so there a favourite stays after a switch to a downvote. Feeds followed (owner decision 2026-10-07: through the server). Following a feed (Lemmy's multi-community, PieFed's feed) keeps a FeedSubscription for the persona and nothing else; the new Service privapub_feeds (LocalActorKind.Reader, reserved by migration _017) follows every community of the feeds read here, reconciled when a persona follows or leaves one, when a feed's list is read again (kept as it was when it cannot be read) and every six hours. Its Following rows carry FollowerKind, so nobody's home gets what it brings in and its unanswered follows are sent again as its own. The persona reads GET /api/v1/timelines/feed/:id (the feed's threads, ours included) and lists its feeds at GET /api/v1/feeds. Checked in the pasture, every scenario: 873 pass. The 14 failures are Hubzilla's (identical on the previous commit: Hubzilla no longer answers a follow in this pasture since its restore) and two activities Smithereen never sent; followsync passes once the pasture's restore is older than the 14-day pause. Live: alice's downvotes count as downvotes on Lemmy 1.0 and PieFed, replacing her upvote; Lemmy 1.0 and PieFed take privapub_feeds' follows and their threads reach the feed timelines. 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
3b3f1f6b93
commit
6fccb6fe35
32 files changed
+577
-45
No files matched your search
@@ -64,6 +64,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
Task<StatusOutcome> Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token);
|
||||
Task<StatusOutcome> Remove(LocalActor author, string postId, CancellationToken token);
|
||||
Task<StatusOutcome> Favourite(LocalActor me, string postId, bool on, CancellationToken token);
|
||||
Task<StatusOutcome> Downvote(LocalActor me, string postId, bool on, CancellationToken token);
|
||||
Task<StatusOutcome> Reblog(LocalActor me, string postId, bool on, PostVisibility visibility, CancellationToken token);
|
||||
}
|
||||
|
||||
@@ -438,6 +439,9 @@ namespace PrivaPub.Domain.Statuses
|
||||
var likePermission = on ? await Permission(post, me, InteractionKind.Like, token) : QuotePermission.Granted;
|
||||
if (likePermission == QuotePermission.Denied)
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take likes from you");
|
||||
// one vote a post: an upvote replaces the downvote, here and, by its Like alone, there
|
||||
if (on)
|
||||
await Forget<Downvote>(d => d.PostId == post.ID && d.ActorURI == me.Uri, post, p => p.DownvotesCount, token);
|
||||
|
||||
// each favourite is a Like of its own, as Mastodon's are: a favourite after an unfavourite is a new Like, which no
|
||||
// server (and no delivery queue) takes for the one already undone; an unfavourite undoes the Like it ends
|
||||
@@ -472,7 +476,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
post.FavouritesCount--;
|
||||
}
|
||||
|
||||
if (post.IsFederatedCopy && await AuthorInbox(post, token) is { } inbox)
|
||||
if (post.IsFederatedCopy && await VoteInboxes(post, token) is { Count: > 0 } inboxes)
|
||||
{
|
||||
var like = new JsonObject
|
||||
{
|
||||
@@ -485,11 +489,95 @@ namespace PrivaPub.Domain.Statuses
|
||||
if (on && favourite.Approval == ApprovalState.Pending)
|
||||
await _approvals.Ask(me, post, InteractionKind.Like, like, favourite.ID, token);
|
||||
else
|
||||
await _delivery.Enqueue(me, new[] { inbox }, on ? like : Undo(me, like, $"undo-like-{favourite.ID}"), token);
|
||||
await _delivery.Enqueue(me, inboxes, on ? like : Undo(me, like, $"undo-like-{favourite.ID}"), token);
|
||||
}
|
||||
return new StatusOutcome(post);
|
||||
}
|
||||
|
||||
// A downvote, as the threadiverse counts them (Lemmy, PieFed): a `Dislike` to the post's author and its community,
|
||||
// `Undo{Dislike}` to take it back. One vote a post: a downvote replaces the upvote by its Dislike alone, as Lemmy
|
||||
// sends a changed vote, since an Undo{Like} sent beside it could arrive after it and take the downvote away (Mbin,
|
||||
// which keeps a favourite apart, keeps it). Public, as a favourite is (owner decision: the client tells each
|
||||
// persona once).
|
||||
public async Task<StatusOutcome> Downvote(LocalActor me, string postId, bool on, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(me, postId, token);
|
||||
if (post?.ReblogOfPostId != default)
|
||||
post = await Visible(me, post.ReblogOfPostId, token);
|
||||
if (post == default)
|
||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||
|
||||
Downvote downvote;
|
||||
if (on)
|
||||
{
|
||||
await Forget<Favourite>(f => f.AccountId == me.Id && f.PostId == post.ID, post, p => p.FavouritesCount, token);
|
||||
downvote = new Downvote { ID = ObjectId.GenerateNewId().ToString(), AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID };
|
||||
downvote.ActivityURI = me.ActivityUri($"dislike-{downvote.ID}");
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(downvote, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return new StatusOutcome(post);
|
||||
}
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token);
|
||||
post.DownvotesCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
downvote = await DB.Default.Find<Downvote>().Match(d => d.PostId == post.ID && d.ActorURI == me.Uri).ExecuteFirstAsync(token);
|
||||
if (downvote == default || (await DB.Default.DeleteAsync<Downvote>(downvote.ID)).DeletedCount == 0)
|
||||
return new StatusOutcome(post);
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token);
|
||||
post.DownvotesCount--;
|
||||
}
|
||||
|
||||
if (post.IsFederatedCopy && await VoteInboxes(post, token) is { Count: > 0 } inboxes)
|
||||
{
|
||||
var dislike = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = downvote.ActivityURI,
|
||||
["type"] = "Dislike",
|
||||
["actor"] = me.Uri,
|
||||
["object"] = post.ObjectURI
|
||||
};
|
||||
await _delivery.Enqueue(me, inboxes, on ? dislike : Undo(me, dislike, $"undo-dislike-{downvote.ID}"), token);
|
||||
}
|
||||
return new StatusOutcome(post);
|
||||
}
|
||||
|
||||
// the persona's other vote on the post, dropped here alone: the vote that replaces it says so there
|
||||
async Task Forget<TVote>(System.Linq.Expressions.Expression<Func<TVote, bool>> mine, PostEntity post,
|
||||
System.Linq.Expressions.Expression<Func<PostEntity, int>> count, CancellationToken token) where TVote : Entity
|
||||
{
|
||||
var vote = await DB.Default.Find<TVote>().Match(mine).ExecuteFirstAsync(token);
|
||||
if (vote == default || (await DB.Default.DeleteAsync<TVote>(vote.ID)).DeletedCount == 0)
|
||||
return;
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(count, -1)).ExecuteAsync(token);
|
||||
if (typeof(TVote) == typeof(Favourite))
|
||||
post.FavouritesCount--;
|
||||
else
|
||||
post.DownvotesCount--;
|
||||
}
|
||||
|
||||
// where a vote on a remote post goes: the community it was made in, which counts the votes and passes them on, as
|
||||
// Lemmy sends one; and its author when on another server (a Mastodon account's post in a Lemmy community). One copy a
|
||||
// server: PieFed drops the second it is sent within a minute, though its first may yet fail.
|
||||
async Task<List<string>> VoteInboxes(PostEntity post, CancellationToken token)
|
||||
{
|
||||
var inboxes = new List<string>();
|
||||
if (await Federation.Inbox.Communities.Of(post, _dbEntities, token) is { } communityUri
|
||||
&& await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == communityUri && f.AvatarType == AvatarType.Group).ExecuteFirstAsync(token) is { } community
|
||||
&& !string.IsNullOrEmpty(community.InboxURL))
|
||||
inboxes.Add(community.InboxURL);
|
||||
if (await AuthorInbox(post, token) is { } author
|
||||
&& !inboxes.Any(i => string.Equals(new Uri(i).Authority, new Uri(author).Authority, StringComparison.OrdinalIgnoreCase)))
|
||||
inboxes.Add(author);
|
||||
return inboxes;
|
||||
}
|
||||
|
||||
// how a remote post's interaction policy takes an interaction of ours: at once, once asked, or not at all
|
||||
async Task<QuotePermission> Permission(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token) =>
|
||||
target is { IsFederatedCopy: true } && _approvals != default ? await _approvals.Judge(target, actor, kind, token) : QuotePermission.Granted;
|
||||
|
||||
Reference in new issue
Block a user