Replies a post's author approves (FEP-5624), checked live with PeerTube

PeerTube puts canReply on a video whose comments wait for approval, and answers each comment with ApproveReply. A
persona's reply to such a post now waits (privapub.approval: pending), its Create going to the author alone; the
author's ApproveReply, signed by the author and naming the post answered, lets it out to its audience with
replyApproval, and RejectReply leaves it ours. A null canReply (PeerTube's open comments) says nothing; an empty one
refuses. The PeerTube scenario holds a comment for review and approves it through PeerTube's API (28 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 16:16:34 +02:00
1 parent 5d5f1d6909
commit a3a66b6db2
17 files changed
+256 -25

No files matched your search

@@ -0,0 +1,35 @@
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.User;
using System.Text.Json.Nodes;
namespace PrivaPub.Federation.Inbox.Handlers
{
// FEP-5624 (PeerTube): the author of a post whose canReply asks it to approve each reply lets one of ours in, which then
// goes to its audience carrying the approval (replyApproval)
public class ReplyApprovalHandler : IActivityHandler
{
readonly IInteractionApprovals _approvals;
public ReplyApprovalHandler(IInteractionApprovals approvals) => _approvals = approvals;
public virtual string Type => "ApproveReply";
protected virtual bool Approves => true;
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) =>
await _approvals.Answered(activity, actor, Approves, token);
}
// and refuses one, which stays ours alone
public class ReplyRejectionHandler : ReplyApprovalHandler
{
public ReplyRejectionHandler(IInteractionApprovals approvals) : base(approvals)
{
}
public override string Type => "RejectReply";
protected override bool Approves => false;
}
}
+1
View File
@@ -33,6 +33,7 @@ namespace PrivaPub.Federation.Inbox
post.ReplyPolicy = note.ReplyPolicy;
post.LikePolicy = note.LikePolicy;
post.AnnouncePolicy = note.AnnouncePolicy;
post.ReplyApprovals = note.ReplyApprovals;
post.Video = note.Video ?? post.Video;
post.Audio = note.Audio ?? post.Audio;
post.Event = note.Event ?? post.Event;
+1
View File
@@ -110,6 +110,7 @@ namespace PrivaPub.Federation.Inbox
ReplyPolicy = note.ReplyPolicy,
LikePolicy = note.LikePolicy,
AnnouncePolicy = note.AnnouncePolicy,
ReplyApprovals = note.ReplyApprovals,
Emojis = note.Emojis.ToList(),
CoverURL = note.CoverURL,
Link = note.Link,
@@ -44,6 +44,7 @@ namespace PrivaPub.Federation.Objects
public InteractionRule ReplyPolicy { get; init; }
public InteractionRule LikePolicy { get; init; }
public InteractionRule AnnouncePolicy { get; init; }
public InteractionRule ReplyApprovals { get; init; }//FEP-5624's canReply: who may reply, each reply then approved or refused
public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>();
public string CoverURL { get; init; }
public PostLink Link { get; init; }
@@ -122,6 +123,7 @@ namespace PrivaPub.Federation.Objects
ReplyPolicy = ObjectShapes.Policy(note, "canReply"),
LikePolicy = ObjectShapes.Policy(note, "canLike"),
AnnouncePolicy = ObjectShapes.Policy(note, "canAnnounce"),
ReplyApprovals = ObjectShapes.ReplyApprovals(note),
Emojis = ObjectShapes.Emojis(note["tag"]),
CoverURL = ObjectShapes.Cover(note),
Link = ObjectShapes.Link(note),
+13 -6
View File
@@ -102,16 +102,23 @@ namespace PrivaPub.Federation.Objects
// one rule of a post's interactionPolicy (GoToSocial, FEP-044f): who may, automatically or once asked; the old names
// (always, approvalRequired) too. A rule left out means anyone, automatically
static List<string> Who(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default).Take(MaxAudience).ToList(),
JsonNode single when Id(single) is { } id => new List<string> { id },
_ => new List<string>()
};
// FEP-5624's canReply (PeerTube's, on a video whose comments wait for approval): who may reply, each reply then
// approved by the author with an ApproveReply, or refused with a RejectReply; null when the post states none (PeerTube
// sends null for a video whose comments are open, and an empty list where they are closed)
public static InteractionRule ReplyApprovals(JsonObject note) =>
note["canReply"] is { } canReply ? new InteractionRule { Manual = Who(canReply) } : default;
public static InteractionRule Policy(JsonObject note, string rule)
{
if (note["interactionPolicy"] is not JsonObject policy || policy[rule] is not JsonObject allowed)
return default;
static List<string> Who(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default).Take(MaxAudience).ToList(),
JsonNode single when Id(single) is { } id => new List<string> { id },
_ => new List<string>()
};
return new InteractionRule
{
Automatic = Who(allowed["automaticApproval"] ?? allowed["always"]),
@@ -63,6 +63,7 @@ namespace PrivaPub.Federation.Rendering
["AnnounceAuthorization"] = "gts:AnnounceAuthorization",
["likeAuthorization"] = new JsonObject { ["@id"] = "gts:likeAuthorization", ["@type"] = "@id" },
["replyAuthorization"] = new JsonObject { ["@id"] = "gts:replyAuthorization", ["@type"] = "@id" },
["replyApproval"] = new JsonObject { ["@id"] = "toot:replyApproval", ["@type"] = "@id" },
["announceAuthorization"] = new JsonObject { ["@id"] = "gts:announceAuthorization", ["@type"] = "@id" },
["litepub"] = "http://litepub.social/ns#",
["EmojiReact"] = "litepub:EmojiReact",
@@ -408,6 +409,8 @@ namespace PrivaPub.Federation.Rendering
// the parent's author let this reply in (GoToSocial's interaction policies)
if (!string.IsNullOrEmpty(inReplyTo) && !string.IsNullOrEmpty(post.ApprovalURI))
note["replyAuthorization"] = post.ApprovalURI;
if (!string.IsNullOrEmpty(inReplyTo) && !string.IsNullOrEmpty(post.ReplyApprovalURI))
note["replyApproval"] = post.ReplyApprovalURI;
if (post.EditedAt.HasValue)
note["updated"] = Timestamp(post.EditedAt.Value);
return note;