P6 done: personas can be quoted, under the owner's default of anyone, automatically
- Our public and unlisted posts state interactionPolicy.canQuote. The policy comes from the post, then the persona
(`source[quote_policy]`: public, followers or nobody; default public as the owner chose), and is always nobody for
followers-only posts and DMs.
- QuoteRequests are answered with Accept{result} naming a parrot-licence at /peasants/{name}/parrot-licences/{id}
(the route name the owner chose), or with Reject. Followers-only checks that the requester really follows.
- A licence is a QuoteAuthorization naming both posts; revoking it (POST /api/v1/statuses/:id/quotes/:quoting_id/revoke)
marks it 410, sends Delete{licence} to the quoter and the persona's followers, and revokes our own copy of the quote.
- A quote that arrives with one of our licences is accepted only if that licence is ours, unrevoked and names exactly
that quoting post. One persona quoting another gets a licence too.
- Mastodon API: quote_approval for our posts (automatic, followers, current_user), `quote_approval_policy` when posting,
PUT /api/v1/statuses/:id/interaction_policy, `source.quote_policy`.
Checked live: GoToSocial still accepts our posts with the policy stated, and leaves likes, replies and boosts open.
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
f9658a8e7a
commit
e6729c77e7
19 files changed
+372
-17
No files matched your search
@@ -48,6 +48,7 @@ actor-scoped route gets a name in the same spirit, agreed with the owner, and a
|
||||
| Notes | `/peasants/{name}/scribbles/{id}` |
|
||||
| Activities | `/peasants/{name}/grunts/{id}` (`create-{postId}` resolves) |
|
||||
| DM context | `/peasants/{name}/whispers/{id}` |
|
||||
| Quote permission (FEP-044f `QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
|
||||
| Token refresh | `/clientapi/user/sniff/again` |
|
||||
|
||||
Agreed for later phases: `/gossip`, `/drool`, `/echoes` (replies, likes, shares), `/trophies` (featured), `/tattoos`
|
||||
@@ -279,6 +280,10 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
||||
`QuotesCount` moves with the accepted state, never with the raw key. When a persona quotes, `QuotePermission` decides:
|
||||
asking first for posts that state a policy, quoting at once for posts that state none, refusing otherwise. Only `quote`
|
||||
of a post that asks for consent puts `quote` in our JSON; older-key quotes leave it out, as Sharkey learned they must.
|
||||
- **Our quote policy is `ActivityPubRenderer.QuotableBy`:** the post's `LocalQuotePolicy`, falling back to the persona's
|
||||
`Settings.QuotePolicy` (default `public`, owner decision), and always `nobody` for anything but public and unlisted. The
|
||||
same rule writes `canQuote`, answers `QuoteRequest`s (`QuoteService.ReceiveRequest`) and fills `quote_approval`, so they
|
||||
cannot disagree. A persona quoting another persona gets a parrot-licence too, so other servers see an approved quote.
|
||||
- **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its
|
||||
NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything.
|
||||
- **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post
|
||||
|
||||
+8
-1
@@ -50,6 +50,7 @@ actor document, a collection, NodeInfo or a delivery relates two avatars of the
|
||||
| Followers / following | `/peasants/{name}/groupies`, `/peasants/{name}/stalking` |
|
||||
| Objects | `/peasants/{name}/scribbles/{id}` |
|
||||
| Activities | `/peasants/{name}/grunts/{id}` |
|
||||
| Quote permissions (`QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
|
||||
| Direct-message context | `/peasants/{name}/whispers/{id}` |
|
||||
| Profile and post pages | `/@{name}`, `/@{name}/{id}` |
|
||||
|
||||
@@ -130,7 +131,13 @@ An incoming `Update` without a newer `updated` only refreshes counts and details
|
||||
stamp we can verify; the post is then updated with `quoteAuthorization`.
|
||||
- Posts that state no policy are quoted the older way, without `quote`.
|
||||
- Quoting posts are also delivered to the quoted author.
|
||||
- **Not yet.** Our own posts cannot be quoted by others yet: we do not issue stamps.
|
||||
- **Quoting our accounts.** Our public and unlisted posts state `interactionPolicy.canQuote`. By default anyone may quote,
|
||||
automatically (as on Mastodon); an account can choose followers only or nobody, and so can each post.
|
||||
- A `QuoteRequest` is answered with `Accept{result}` naming a stamp at `/peasants/{name}/parrot-licences/{id}`, or
|
||||
with `Reject`.
|
||||
- A stamp is a `QuoteAuthorization` naming both posts. A revoked stamp answers 410 and is announced with
|
||||
`Delete{stamp}`.
|
||||
- Followers-only posts and DMs can never be quoted.
|
||||
|
||||
**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object.
|
||||
**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`,
|
||||
|
||||
@@ -139,15 +139,15 @@ namespace PrivaPub.Tests.Federation
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_legacy_post_is_quoted_without_asking_and_another_persona_cannot_be_quoted_yet()
|
||||
public async Task A_legacy_post_is_quoted_without_asking_and_a_persona_that_allows_nobody_cannot_be_quoted()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (aliceRoot, alice) = await _harness.Persona("alice");
|
||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||
var kitty = new RemoteActor(_harness.Peer, "kitty");
|
||||
var legacy = await Delivered(kitty, alice.Uri);
|
||||
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = "mine" }, token);
|
||||
var bobs = await DB.Default.Find<Post>().Match(p => p.GroupUserId == bob.Id).ExecuteFirstAsync(token);
|
||||
bob.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Nobody;
|
||||
var bobs = (await _harness.Statuses.Publish(bob, new StatusDraft { Text = "mine" }, token)).Post;
|
||||
|
||||
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "nice", QuotedStatusId = legacy.ID }, token);
|
||||
Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState);
|
||||
@@ -155,5 +155,67 @@ namespace PrivaPub.Tests.Federation
|
||||
Assert.False((await _harness.Statuses.Publish(alice, new StatusDraft { Text = "and this", QuotedStatusId = bobs.ID }, token)).Ok);
|
||||
Assert.True((await _harness.Statuses.Publish(bob, new StatusDraft { Text = "me again", QuotedStatusId = bobs.ID }, token)).Ok);
|
||||
}
|
||||
|
||||
JsonObject Request(RemoteActor quoter, string quotedUri, string quotingId) => new()
|
||||
{
|
||||
["id"] = NewId(quoter, "activities"), ["type"] = "QuoteRequest", ["actor"] = quoter.Id, ["object"] = quotedUri,
|
||||
["instrument"] = new JsonObject { ["id"] = quotingId, ["type"] = "Note", ["attributedTo"] = quoter.Id, ["quote"] = quotedUri, ["content"] = "<p>so true</p>" }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Anyone_may_quote_a_persona_by_default_and_the_licence_can_be_revoked()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||
await _harness.Deliver(mallory, "/human-centipede", new JsonObject { ["id"] = NewId(mallory, "follows"), ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri });
|
||||
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "quote me" }, token)).Post;
|
||||
var created = (await _harness.Outgoing(mallory.Id + "/inbox")).Single(a => a["type"]!.GetValue<string>() == "Create")["object"]!;
|
||||
Assert.Equal(Addressing.Public, created["interactionPolicy"]!["canQuote"]!["automaticApproval"]![0]!.GetValue<string>());
|
||||
|
||||
var quotingId = NewId(mallory, "notes");
|
||||
await _harness.Deliver(mallory, "/human-centipede", Request(mallory, original.ObjectURI, quotingId));
|
||||
var accept = Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Accept" && a["result"] != null);
|
||||
var licence = accept["result"]!.GetValue<string>();
|
||||
Assert.StartsWith(alice.Uri + "/parrot-licences/", licence);
|
||||
|
||||
var quoting = await Delivered(mallory, alice.Uri, n => { n["id"] = quotingId; n["quote"] = original.ObjectURI; n["quoteAuthorization"] = licence; });
|
||||
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
|
||||
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
|
||||
|
||||
Assert.True(await _harness.Quotes.RevokeLicence(original, quoting, token));
|
||||
Assert.Equal(QuoteState.Revoked, (await DB.Default.Find<Post>().OneAsync(quoting.ID, token)).QuoteState);
|
||||
Assert.Contains(await _harness.Outgoing(mallory.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Delete" && a["object"]!.GetValue<string>() == licence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_followers_only_persona_turns_a_stranger_down()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
alice.Settings.QuotePolicy = PrivaPub.Models.User.QuotePolicies.Followers;
|
||||
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "friends only quoting" }, token)).Post;
|
||||
var stranger = new RemoteActor(_harness.Peer, "stranger");
|
||||
|
||||
await _harness.Deliver(stranger, "/human-centipede", Request(stranger, original.ObjectURI, NewId(stranger, "notes")));
|
||||
|
||||
Assert.Equal("Reject", Assert.Single(await _harness.Outgoing(stranger.Id + "/inbox"))["type"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_persona_quotes_another_with_a_licence()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var (_, bob) = await _harness.Persona("bob");
|
||||
var original = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "public thought" }, token)).Post;
|
||||
|
||||
var outcome = await _harness.Statuses.Publish(bob, new StatusDraft { Text = "agreed", QuotedStatusId = original.ID }, token);
|
||||
|
||||
Assert.True(outcome.Ok);
|
||||
Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState);
|
||||
Assert.StartsWith(alice.Uri + "/parrot-licences/", outcome.Post.QuoteAuthorizationURI);
|
||||
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ namespace PrivaPub.Tests.Support
|
||||
new UndoHandler(Db, Local, Reactions),
|
||||
new LikeHandler(Db, Reactions),
|
||||
new EmojiReactHandler(Db, Reactions),
|
||||
new QuoteRequestHandler(Quotes),
|
||||
new DislikeHandler(Db),
|
||||
new JoinHandler(Db, Local, Delivery),
|
||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
|
||||
|
||||
@@ -79,6 +79,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
avatar.Settings.DefaultSensitive = sensitive;
|
||||
if (Params.Has("source[language]"))
|
||||
avatar.Settings.DefaultLanguage = Params.Get("source[language]");
|
||||
if (Params.Get("source[quote_policy]") is { } quotePolicy && QuotePolicies.IsKnown(quotePolicy))
|
||||
avatar.Settings.QuotePolicy = quotePolicy;
|
||||
var fields = new Dictionary<string, string>();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
|
||||
@@ -9,6 +9,9 @@ using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
@@ -24,9 +27,14 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IMemoryCache _cache;
|
||||
readonly IQuoteService _quotes;
|
||||
readonly IOutboxPublisher _outbox;
|
||||
|
||||
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache)
|
||||
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes,
|
||||
IOutboxPublisher outbox)
|
||||
{
|
||||
_quotes = quotes;
|
||||
_outbox = outbox;
|
||||
_statuses = statuses;
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
@@ -55,6 +63,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage,
|
||||
MediaIds = Params.List("media_ids"),
|
||||
QuotedStatusId = Params.Get("quoted_status_id"),
|
||||
QuotePolicy = Params.Get("quote_approval_policy"),
|
||||
Poll = Params.Has("poll[options]")
|
||||
? new PollDraft(Params.List("poll[options]"), Params.Int("poll[expires_in]") ?? 0, Params.Bool("poll[multiple]") == true,
|
||||
Params.Bool("poll[hide_totals]") == true)
|
||||
@@ -242,6 +251,32 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return Json(await _mapper.Statuses(visible, MyId, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/quotes/{quotingId}/revoke"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> RevokeQuote(string id, string quotingId, CancellationToken token)
|
||||
{
|
||||
var quoted = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
var quoting = quoted == default ? default : await _dbEntities.Posts.Match(p => p.ID == quotingId && p.QuotedPostId == quoted.ID).ExecuteFirstAsync(token);
|
||||
if (quoting == default || !await _quotes.RevokeLicence(quoted, quoting, token))
|
||||
return NotFoundError();
|
||||
return Json(await _mapper.Status(await _dbEntities.Posts.MatchID(quoting.ID).ExecuteFirstAsync(token), MyId, token));
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/statuses/{id}/interaction_policy"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> InteractionPolicy(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
var policy = Params.Get("quote_approval_policy");
|
||||
if (!QuotePolicies.IsKnown(policy))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Unknown quote approval policy");
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.LocalQuotePolicy, policy).ExecuteAsync(token);
|
||||
post.LocalQuotePolicy = policy;
|
||||
if (!post.IsLocalOnly)
|
||||
await _outbox.Publish(Me, post, ActivityPubRenderer.UpdateOf(post, Me, $"policy-{DateTime.UtcNow.Ticks}"), token);
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")]
|
||||
public async Task<IActionResult> Bookmark(string id, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -59,6 +59,7 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public List<Field> Fields { get; set; } = new();
|
||||
public int FollowRequestsCount { get; set; }
|
||||
public string AttributionDomains { get; set; }
|
||||
public string QuotePolicy { get; set; } = "public";
|
||||
}
|
||||
|
||||
public class PrivaPubStatus
|
||||
|
||||
@@ -93,6 +93,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
Privacy = actor.Settings.DefaultVisibility ?? "public",
|
||||
Sensitive = actor.Settings.DefaultSensitive,
|
||||
Language = actor.Settings.DefaultLanguage,
|
||||
QuotePolicy = actor.Settings.QuotePolicy ?? QuotePolicies.Public,
|
||||
Note = actor.Summary ?? string.Empty,
|
||||
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
|
||||
FollowRequestsCount = (int)await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && !f.IsAccepted, token)
|
||||
@@ -238,6 +239,10 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
return status;
|
||||
}
|
||||
|
||||
var localAuthors = all.Where(p => !p.IsFederatedCopy && p.GroupUserId != default).Select(p => p.GroupUserId).Distinct().ToList();
|
||||
var followsAuthor = viewerId == default || localAuthors.Count == 0
|
||||
? new HashSet<string>()
|
||||
: await FollowedLocalAuthors(viewerId, localAuthors, token);
|
||||
var quotedIds = nested ? new List<string>() : all.Where(p => p.QuoteState == QuoteState.Accepted && p.QuotedPostId != default).Select(p => p.QuotedPostId).Distinct().ToList();
|
||||
var quotedPosts = new List<PostEntity>();
|
||||
foreach (var quotedPost in quotedIds.Count == 0 ? new List<PostEntity>() : await _dbEntities.Posts.Match(p => quotedIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token))
|
||||
@@ -250,7 +255,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
void Quote(Status status, PostEntity post)
|
||||
{
|
||||
status.QuotesCount = post.QuotesCount;
|
||||
status.QuoteApproval = Approval(post, viewerId);
|
||||
status.QuoteApproval = post.IsFederatedCopy ? Approval(post, viewerId) : LocalApproval(post, viewerId, followsAuthor.Contains(post.GroupUserId));
|
||||
if (post.QuoteState == QuoteState.None)
|
||||
return;
|
||||
var state = post.QuoteState.ToString().ToLowerInvariant();
|
||||
@@ -300,6 +305,27 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
return document.Body!.InnerHtml;
|
||||
}
|
||||
|
||||
async Task<HashSet<string>> FollowedLocalAuthors(string viewerId, List<string> authorIds, CancellationToken token)
|
||||
{
|
||||
var uris = new Dictionary<string, string>();
|
||||
foreach (var avatar in await _dbEntities.Avatars.Match(a => authorIds.Contains(a.ID)).ExecuteAsync(token))
|
||||
uris[_localActors.FromAvatar(avatar).Uri] = avatar.ID;
|
||||
var targets = uris.Keys.ToList();
|
||||
return (await _dbEntities.Followings.Match(f => f.AvatarId == viewerId && targets.Contains(f.TargetActorURI) && f.State == FollowState.Accepted).ExecuteAsync(token))
|
||||
.Select(f => uris[f.TargetActorURI]).ToHashSet();
|
||||
}
|
||||
|
||||
static QuoteApprovalEntity LocalApproval(PostEntity post, string viewerId, bool viewerFollows)
|
||||
{
|
||||
var policy = ActivityPubRenderer.QuotableBy(post);
|
||||
var everyone = policy switch { QuotePolicies.Public => new List<string> { "public" }, QuotePolicies.Followers => new List<string> { "followers" }, _ => new List<string>() };
|
||||
var current = viewerId == default ? "unknown"
|
||||
: viewerId == post.GroupUserId && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? "automatic"
|
||||
: policy == QuotePolicies.Public || policy == QuotePolicies.Followers && viewerFollows ? "automatic"
|
||||
: "denied";
|
||||
return new QuoteApprovalEntity { Automatic = everyone, CurrentUser = current };
|
||||
}
|
||||
|
||||
static QuoteApprovalEntity Approval(PostEntity post, string viewerId)
|
||||
{
|
||||
if (!post.IsFederatedCopy || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
||||
|
||||
@@ -25,7 +25,10 @@ namespace PrivaPub.Domain.Statuses
|
||||
Task Resolve(PostEntity post, NoteDocument note, CancellationToken token);
|
||||
Task Revoke(string stampUri, CancellationToken token);
|
||||
Task<bool> Verified(string stampUri, string quotingUri, PostEntity quoted, CancellationToken token);
|
||||
QuotePermission Permission(PostEntity quoted, LocalActor author);
|
||||
Task<QuotePermission> Permission(PostEntity quoted, LocalActor author, CancellationToken token);
|
||||
Task<string> Grant(LocalActor author, PostEntity quoted, string quotingUri, string quoterUri, CancellationToken token);
|
||||
Task ReceiveRequest(JsonNode request, ForeignAvatar actor, CancellationToken token);
|
||||
Task<bool> RevokeLicence(PostEntity quoted, PostEntity quoting, CancellationToken token);
|
||||
Task Request(LocalActor author, PostEntity post, PostEntity quoted, JsonObject note, CancellationToken token);
|
||||
Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token);
|
||||
}
|
||||
@@ -59,12 +62,115 @@ namespace PrivaPub.Domain.Statuses
|
||||
|
||||
const string RequestPrefix = "quote-request-";
|
||||
|
||||
public QuotePermission Permission(PostEntity quoted, LocalActor author)
|
||||
const string LicencePath = "/parrot-licences/";
|
||||
|
||||
public static string LicenceUri(LocalActor author, string licenceId) => author.Uri + LicencePath + licenceId;
|
||||
|
||||
async Task<bool> MayQuote(PostEntity quoted, string quoterUri, CancellationToken token)
|
||||
{
|
||||
var author = await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token);
|
||||
if (author == default)
|
||||
return false;
|
||||
if (quoterUri == author.Uri)
|
||||
return quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted;
|
||||
return ActivityPubRenderer.QuotableBy(quoted) switch
|
||||
{
|
||||
QuotePolicies.Public => true,
|
||||
QuotePolicies.Followers => await Follows(quoterUri, author, token),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
async Task<bool> Follows(string followerUri, LocalActor author, CancellationToken token)
|
||||
{
|
||||
if (await _dbEntities.Followers.Match(f => f.LocalActorId == author.Id && f.ActorURI == followerUri && f.IsAccepted).ExecuteAnyAsync(token))
|
||||
return true;
|
||||
var local = await _localActors.FindByUri(followerUri, token);
|
||||
return local != default
|
||||
&& await _dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.TargetActorURI == author.Uri && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
|
||||
}
|
||||
|
||||
public async Task<string> Grant(LocalActor author, PostEntity quoted, string quotingUri, string quoterUri, CancellationToken token)
|
||||
{
|
||||
var existing = await DB.Default.Find<QuoteLicence>().Match(l => l.PostId == quoted.ID && l.QuotingObjectURI == quotingUri && l.RevokedAt == null).ExecuteFirstAsync(token);
|
||||
if (existing != default)
|
||||
return LicenceUri(author, existing.ID);
|
||||
var licence = new QuoteLicence { PostId = quoted.ID, AuthorAvatarId = author.Id, QuotingObjectURI = quotingUri, QuoterActorURI = quoterUri };
|
||||
await DB.Default.SaveAsync(licence, token);
|
||||
return LicenceUri(author, licence.ID);
|
||||
}
|
||||
|
||||
public async Task ReceiveRequest(JsonNode request, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var objectUri = Id(request["object"]);
|
||||
var instrument = request["instrument"];
|
||||
var quotingUri = Id(instrument);
|
||||
if (objectUri == default || quotingUri == default || !Origin.Same(quotingUri, actor.ActorURI)
|
||||
|| instrument is JsonObject embedded && Id(embedded["attributedTo"]) is { } by && by != actor.ActorURI)
|
||||
return;
|
||||
var quoted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
var author = quoted == default ? default : await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token);
|
||||
if (author == default || string.IsNullOrEmpty(actor.InboxURL))
|
||||
return;
|
||||
var answer = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(),
|
||||
["actor"] = author.Uri,
|
||||
["object"] = Id(request),
|
||||
["to"] = new JsonArray(actor.ActorURI)
|
||||
};
|
||||
if (await MayQuote(quoted, actor.ActorURI, token))
|
||||
{
|
||||
answer["type"] = "Accept";
|
||||
answer["result"] = await Grant(author, quoted, quotingUri, actor.ActorURI, token);
|
||||
answer["id"] = author.ActivityUri($"accept-quote-{Guid.NewGuid():N}");
|
||||
}
|
||||
else
|
||||
{
|
||||
answer["type"] = "Reject";
|
||||
answer["id"] = author.ActivityUri($"reject-quote-{Guid.NewGuid():N}");
|
||||
}
|
||||
await _delivery.Enqueue(author, new[] { actor.InboxURL }, answer, token);
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeLicence(PostEntity quoted, PostEntity quoting, CancellationToken token)
|
||||
{
|
||||
var licence = await DB.Default.Find<QuoteLicence>().Match(l => l.PostId == quoted.ID && l.QuotingObjectURI == quoting.ObjectURI && l.RevokedAt == null).ExecuteFirstAsync(token);
|
||||
var author = licence == default ? default : await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token);
|
||||
if (author == default)
|
||||
return false;
|
||||
await DB.Default.Update<QuoteLicence>().MatchID(licence.ID).Modify(l => l.RevokedAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||
var uri = LicenceUri(author, licence.ID);
|
||||
await Revoke(uri, token);
|
||||
var quoter = quoting.IsFederatedCopy ? await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == quoting.ActorURI).ExecuteFirstAsync(token) : default;
|
||||
var inboxes = (await _delivery.FollowerInboxes(author, token)).Append(quoter?.InboxURL).Where(i => !string.IsNullOrEmpty(i)).Distinct().ToList();
|
||||
if (inboxes.Count > 0)
|
||||
await _delivery.Enqueue(author, inboxes, new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(),
|
||||
["id"] = author.ActivityUri($"revoke-quote-{licence.ID}"),
|
||||
["type"] = "Delete",
|
||||
["actor"] = author.Uri,
|
||||
["object"] = uri,
|
||||
["to"] = new JsonArray(Addressing.Public)
|
||||
}, token);
|
||||
return true;
|
||||
}
|
||||
|
||||
async Task<bool> OurLicence(string stampUri, PostEntity quoting, PostEntity quoted, CancellationToken token)
|
||||
{
|
||||
var marker = stampUri?.LastIndexOf(LicencePath, StringComparison.Ordinal) ?? -1;
|
||||
if (marker < 0 || !stampUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
var id = stampUri[(marker + LicencePath.Length)..];
|
||||
return await DB.Default.Find<QuoteLicence>().Match(l => l.ID == id && l.PostId == quoted.ID && l.QuotingObjectURI == quoting.ObjectURI && l.RevokedAt == null)
|
||||
.ExecuteAnyAsync(token);
|
||||
}
|
||||
|
||||
public async Task<QuotePermission> Permission(PostEntity quoted, LocalActor author, CancellationToken token)
|
||||
{
|
||||
if (!quoted.IsFederatedCopy)
|
||||
return quoted.GroupUserId == author.Id && quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted
|
||||
? QuotePermission.Granted
|
||||
: QuotePermission.Denied;
|
||||
return await MayQuote(quoted, author.Uri, token) ? QuotePermission.Granted : QuotePermission.Denied;
|
||||
if (quoted.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
||||
return QuotePermission.Denied;
|
||||
if (quoted.QuotePolicy is not { } policy)
|
||||
@@ -140,7 +246,9 @@ namespace PrivaPub.Domain.Statuses
|
||||
var state = note.QuoteDeleted ? QuoteState.Deleted
|
||||
: quoted == default ? QuoteState.Pending
|
||||
: note.QuoteAuthorization != default
|
||||
? !quoted.IsFederatedCopy || !await Verified(note.QuoteAuthorization, post.ObjectURI, quoted, token) ? QuoteState.Unauthorized : QuoteState.Accepted
|
||||
? (quoted.IsFederatedCopy ? await Verified(note.QuoteAuthorization, post.ObjectURI, quoted, token) : await OurLicence(note.QuoteAuthorization, post, quoted, token))
|
||||
? QuoteState.Accepted
|
||||
: QuoteState.Unauthorized
|
||||
: note.QuotesByConsent ? QuoteState.Pending
|
||||
: quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? QuoteState.Accepted
|
||||
: QuoteState.Unauthorized;
|
||||
|
||||
@@ -13,6 +13,7 @@ using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Media;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
@@ -41,6 +42,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
public double? RangeKm { get; init; }
|
||||
public PollDraft Poll { get; init; }
|
||||
public string QuotedStatusId { get; init; }
|
||||
public string QuotePolicy { get; init; }
|
||||
}
|
||||
|
||||
public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default)
|
||||
@@ -125,7 +127,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
quoted = await _dbEntities.Posts.Match(p => p.ID == draft.QuotedStatusId && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
|
||||
if (quoted == default || !await VisibilityPolicy.CanSee(quoted, author.Id, token))
|
||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||
quotePermission = _quotes.Permission(quoted, author);
|
||||
quotePermission = await _quotes.Permission(quoted, author, token);
|
||||
if (quotePermission == QuotePermission.Denied)
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post cannot be quoted");
|
||||
}
|
||||
@@ -190,6 +192,10 @@ namespace PrivaPub.Domain.Statuses
|
||||
post.ID = (string)post.GenerateNewID();
|
||||
post.ObjectURI = author.PostUri(post.ID);
|
||||
post.Url = author.PostHtmlUrl(post.ID);
|
||||
post.LocalQuotePolicy = QuotePolicies.IsKnown(draft.QuotePolicy) ? draft.QuotePolicy : author.Settings.QuotePolicy ?? QuotePolicies.Public;
|
||||
if (quoted is { IsFederatedCopy: false } && post.QuoteState == QuoteState.Accepted
|
||||
&& await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token) is { } quotedAuthor)
|
||||
post.QuoteAuthorizationURI = await _quotes.Grant(quotedAuthor, quoted, post.ObjectURI, author.Uri, token);
|
||||
if (located)
|
||||
{
|
||||
post.Geo = new Coordinates2D(Math.Round(draft.Longitude.Value, 2), Math.Round(draft.Latitude.Value, 2));
|
||||
|
||||
@@ -199,6 +199,33 @@ namespace PrivaPub.Federation.Controllers
|
||||
return Activity(note);
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/parrot-licences/{licenceId}")]
|
||||
public async Task<IActionResult> ParrotLicence(string actor, string licenceId, CancellationToken token)
|
||||
{
|
||||
var author = await _localActors.FindByUserName(actor, token);
|
||||
var licence = author == default ? default : await DB.Default.Find<QuoteLicence>().Match(l => l.ID == licenceId && l.AuthorAvatarId == author.Id).ExecuteFirstAsync(token);
|
||||
var quoted = licence == default ? default : await _dbEntities.Posts.MatchID(licence.PostId).ExecuteFirstAsync(token);
|
||||
if (quoted == default)
|
||||
return NotFound();
|
||||
var uri = Domain.Statuses.QuoteService.LicenceUri(author, licence.ID);
|
||||
if (licence.RevokedAt.HasValue || quoted.DeletedAt.HasValue)
|
||||
return new ContentResult
|
||||
{
|
||||
Content = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = uri, ["type"] = "Tombstone", ["formerType"] = "QuoteAuthorization" }.ToJsonString(),
|
||||
ContentType = ActivityContentType,
|
||||
StatusCode = StatusCodes.Status410Gone
|
||||
};
|
||||
return Activity(new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(),
|
||||
["id"] = uri,
|
||||
["type"] = "QuoteAuthorization",
|
||||
["attributedTo"] = author.Uri,
|
||||
["interactingObject"] = licence.QuotingObjectURI,
|
||||
["interactionTarget"] = quoted.ObjectURI
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/grunts/{activityId}")]
|
||||
public async Task<IActionResult> Grunt(string actor, string activityId, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Federation.Inbox.Handlers
|
||||
{
|
||||
public class QuoteRequestHandler : IActivityHandler
|
||||
{
|
||||
readonly IQuoteService _quotes;
|
||||
|
||||
public QuoteRequestHandler(IQuoteService quotes)
|
||||
{
|
||||
_quotes = quotes;
|
||||
}
|
||||
|
||||
public string Type => "QuoteRequest";
|
||||
|
||||
public Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) => _quotes.ReceiveRequest(activity, actor, token);
|
||||
}
|
||||
}
|
||||
@@ -43,11 +43,18 @@ namespace PrivaPub.Federation.Rendering
|
||||
["quoteAuthorization"] = new JsonObject { ["@id"] = "https://w3id.org/fep/044f#quoteAuthorization", ["@type"] = "@id" },
|
||||
["QuoteRequest"] = "https://w3id.org/fep/044f#QuoteRequest",
|
||||
["QuoteAuthorization"] = "https://w3id.org/fep/044f#QuoteAuthorization",
|
||||
["interactingObject"] = new JsonObject { ["@id"] = "gts:interactingObject", ["@type"] = "@id" },
|
||||
["interactionTarget"] = new JsonObject { ["@id"] = "gts:interactionTarget", ["@type"] = "@id" },
|
||||
["misskey"] = "https://misskey-hub.net/ns#",
|
||||
["_misskey_quote"] = "misskey:_misskey_quote",
|
||||
["fedibird"] = "http://fedibird.com/ns#",
|
||||
["quoteUri"] = "fedibird:quoteUri",
|
||||
["quoteUrl"] = "as:quoteUrl",
|
||||
["gts"] = "https://gotosocial.org/ns#",
|
||||
["interactionPolicy"] = new JsonObject { ["@id"] = "gts:interactionPolicy", ["@type"] = "@id" },
|
||||
["canQuote"] = new JsonObject { ["@id"] = "gts:canQuote", ["@type"] = "@id" },
|
||||
["automaticApproval"] = new JsonObject { ["@id"] = "gts:automaticApproval", ["@type"] = "@id" },
|
||||
["manualApproval"] = new JsonObject { ["@id"] = "gts:manualApproval", ["@type"] = "@id" },
|
||||
["litepub"] = "http://litepub.social/ns#",
|
||||
["EmojiReact"] = "litepub:EmojiReact",
|
||||
["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" },
|
||||
@@ -188,6 +195,9 @@ namespace PrivaPub.Federation.Rendering
|
||||
};
|
||||
}
|
||||
|
||||
public static string QuotableBy(PostEntity post) =>
|
||||
post.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? post.LocalQuotePolicy ?? Models.User.QuotePolicies.Nobody : Models.User.QuotePolicies.Nobody;
|
||||
|
||||
static void AddQuote(JsonObject note, PostEntity post, ref string content)
|
||||
{
|
||||
if (string.IsNullOrEmpty(post.QuoteURI))
|
||||
@@ -262,6 +272,19 @@ namespace PrivaPub.Federation.Rendering
|
||||
.ToArray())
|
||||
};
|
||||
AddQuote(note, post, ref content);
|
||||
if (!post.IsFederatedCopy)
|
||||
note["interactionPolicy"] = new JsonObject
|
||||
{
|
||||
["canQuote"] = new JsonObject
|
||||
{
|
||||
["automaticApproval"] = new JsonArray((JsonNode)(QuotableBy(post) switch
|
||||
{
|
||||
Models.User.QuotePolicies.Public => Objects.Addressing.Public,
|
||||
Models.User.QuotePolicies.Followers => author.Followers,
|
||||
_ => author.Uri
|
||||
}))
|
||||
}
|
||||
};
|
||||
note["content"] = content;
|
||||
if (!string.IsNullOrEmpty(post.Language))
|
||||
note["contentMap"] = new JsonObject { [post.Language] = content };
|
||||
|
||||
@@ -116,6 +116,7 @@ namespace PrivaPub.Infrastructure.Data
|
||||
await DB.Default.Index<Reaction>().Key(r => r.PostId, KeyType.Ascending).Key(r => r.ActorURI, KeyType.Ascending).Key(r => r.Emoji, KeyType.Ascending)
|
||||
.Option(o => o.Unique = true).CreateAsync(token);
|
||||
await Plain<Reaction>(token, r => r.ActivityURI);
|
||||
await DB.Default.Index<QuoteLicence>().Key(l => l.PostId, KeyType.Ascending).Key(l => l.QuotingObjectURI, KeyType.Ascending).CreateAsync(token);
|
||||
await Unique<Domain.Content.LinkPreview>(p => p.Url, Builders<Domain.Content.LinkPreview>.Filter.Type(p => p.Url, BsonType.String), token);
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IActivityHandler, LikeHandler>()
|
||||
.AddSingleton<IActivityHandler, DislikeHandler>()
|
||||
.AddSingleton<IActivityHandler, EmojiReactHandler>()
|
||||
.AddSingleton<IActivityHandler, QuoteRequestHandler>()
|
||||
.AddSingleton<IReactions, Reactions>()
|
||||
.AddSingleton<LinkPreviews>()
|
||||
.AddSingleton<ILinkPreviews>(services => services.GetRequiredService<LinkPreviews>())
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Federation
|
||||
{
|
||||
public class QuoteLicence : Entity
|
||||
{
|
||||
public string PostId { get; set; }//the quoted local post
|
||||
public string AuthorAvatarId { get; set; }
|
||||
public string QuotingObjectURI { get; set; }
|
||||
public string QuoterActorURI { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,8 @@ namespace PrivaPub.Models.Post
|
||||
public string QuotedPostId { get; set; }//our copy of the quoted post, once fetched
|
||||
public QuoteState QuoteState { get; set; }
|
||||
public string QuoteAuthorizationURI { get; set; }//FEP-044f: the quoted author's stamp
|
||||
public bool QuoteByConsent { get; set; }//our quote of a post whose server asks for consent (FEP-044f), so `quote` is sent
|
||||
public bool QuoteByConsent { get; set; }
|
||||
public string LocalQuotePolicy { get; set; }//who may quote this local post: QuotePolicies.Public, Followers or Nobody//our quote of a post whose server asks for consent (FEP-044f), so `quote` is sent
|
||||
public int QuotesCount { get; set; }
|
||||
[BsonIgnoreIfNull]
|
||||
public InteractionRule QuotePolicy { get; set; }//a remote post's interactionPolicy.canQuote; null when it states none
|
||||
|
||||
@@ -110,6 +110,16 @@ namespace PrivaPub.Models.User
|
||||
public string DefaultVisibility { get; set; } = "public";
|
||||
public bool DefaultSensitive { get; set; }
|
||||
public string DefaultLanguage { get; set; }
|
||||
public string QuotePolicy { get; set; } = QuotePolicies.Public;//owner decision: anyone may quote a public post, automatically
|
||||
}
|
||||
|
||||
public static class QuotePolicies
|
||||
{
|
||||
public const string Public = "public";
|
||||
public const string Followers = "followers";
|
||||
public const string Nobody = "nobody";
|
||||
|
||||
public static bool IsKnown(string value) => value is Public or Followers or Nobody;
|
||||
}
|
||||
|
||||
public enum AvatarServer
|
||||
|
||||
+7
-3
@@ -11,7 +11,7 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
|
||||
- [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet)
|
||||
- [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only
|
||||
- [x] P5 Lose nothing: v1.7.0 to v1.9.1, deployed 2026-10-01; parsing, typed details, provenance, downvotes, tombstones, federated blocks, all checked live against GoToSocial. Book reviews and forum threads keep their raw form only (typed in P7 and P8)
|
||||
- [ ] P6 Emoji, polls, quotes, reactions, cards, players
|
||||
- [x] P6 Emoji, polls, quotes, reactions, cards, players: v1.10.0 to v1.15.0, deployed 2026-10-01; polls, link cards, ranged video streaming and quote policies checked live against GoToSocial
|
||||
- [ ] P7 Threads, communities, moderation, the social graph
|
||||
- [ ] P8 Signatures, discovery, the long tail
|
||||
|
||||
@@ -128,6 +128,8 @@ and circles (see Owner decisions).
|
||||
| PeerTube views | **Never sent.** The remote video file is still downloaded through our proxy when it is played; that cannot be avoided without pre-downloading video. |
|
||||
| Bluesky bridging (Bridgy Fed) | **Allowed per persona**, with a clear warning that the posts become far more widely copied. Leaving the bridge must work, through a federated `Block` sent to the bridge. Persona creation dates are moved back by a random number of days, so personas created the same day no longer share a date. |
|
||||
| Reactions and votes | **Public, with a one-time notice.** The client tells each persona once, before its first reaction or vote, that these are public and visible as that persona. |
|
||||
| Who may quote a persona (default) | **Anyone, automatically**, as on Mastodon, for public and unlisted posts only. Each persona can change its default (followers only, or nobody) and each post can be changed; a granted quote can be revoked. Followers-only posts and DMs can never be quoted. |
|
||||
| Quote permission address | `/peasants/{name}/parrot-licences/{id}` |
|
||||
|
||||
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
|
||||
|
||||
@@ -171,6 +173,7 @@ were agreed on 2026-10-01.
|
||||
| Group members | `/peasants/{group}/flock` | *new* |
|
||||
| Group moderators | `/peasants/{group}/wardens` | *new* |
|
||||
| DM conversation context | `/peasants/{name}/whispers/{id}` | *new*, replaces `/conversations/{id}` |
|
||||
| Quote permission (FEP-044f stamp) | `/peasants/{name}/parrot-licences/{id}` | chosen by the owner, 2026-10-01 |
|
||||
|
||||
Routes outside the actor namespace stay conventional, because other software looks for them by name:
|
||||
- `/.well-known/*` and `/nodeinfo/*`;
|
||||
@@ -456,8 +459,9 @@ it, raw where it doesn't.
|
||||
- read every key; verify `QuoteAuthorization` on all of its fields; handle revocation (done in v1.13.0);
|
||||
- personas quote others: `quoted_status_id`, `QuoteRequest`, `Accept{result}` verified, `Update` with
|
||||
`quoteAuthorization`; legacy quotes for posts that state no policy (done in v1.13.0); `api_versions.mastodon = 7`;
|
||||
- still open: be quotable (`canQuote` on our posts, answer `QuoteRequest`, serve and revoke stamps). It needs a themed
|
||||
route name for stamps, agreed with the owner, and a default quote policy.
|
||||
- be quotable (done in v1.15.0): `canQuote` on our posts (owner's default: anyone, automatically), `QuoteRequest`
|
||||
answered with a parrot-licence or `Reject`, licences served and revoked; personas set `source[quote_policy]`, posts
|
||||
`quote_approval_policy`; `POST /statuses/:id/quotes/:quoting_id/revoke`, `PUT /statuses/:id/interaction_policy`.
|
||||
- **Emoji reactions** in all three inbound forms, plus outbound `EmojiReact`, exposed as `emoji_reactions` (done in
|
||||
v1.11.0; Pleroma's `/api/v1/pleroma/statuses/:id/reactions` endpoints and `pleroma:emoji_reaction` notifications).
|
||||
- **Link cards** (done in v1.12.0):
|
||||
|
||||
Reference in new issue
Block a user