Personas have walls their followers write on
Owner decision of 2026-10-06 (G-0009, FEP-400e). A persona's actor names its wall (…/graffiti, sm:wall) and, in
Smithereen's privacySettings, that its followers may write on it. A public post that is not a reply, sent with the wall
as its target by an account following the persona, is hosted: the persona is notified, it reaches the persona's and its
followers' homes, and the followers' servers and the author's are told with Add{Note}. The persona deletes it with
DELETE /api/v1/statuses/:id, which sends Remove{Note}; Smithereen deletes the post then. The wall lists the persona's
public posts that start a thread and what was written on it.
Elsewhere: an account's Add{Note} on its own wall shows the post to its followers here as on that wall (privapub.wall on
the status), and its Remove takes it away. An Add or Remove naming a collection of the account's own server PrivaPub
does not know has its document read again first, at most hourly: accounts kept before walls were read had none.
Checked live: Smithereen 40 checks (G-0009 closed); GoToSocial and Mastodon unchanged (121).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
e0682fa868
commit
c47b6e5533
26 files changed
+582
-36
No files matched your search
@@ -24,6 +24,7 @@ namespace PrivaPub.Federation.Actors
|
||||
public string Followers { get; init; }
|
||||
public string Following { get; init; }
|
||||
public string Featured { get; init; }
|
||||
public string Wall { get; init; }
|
||||
public string SharedInbox { get; init; }
|
||||
public string Icon { get; init; }
|
||||
public bool Discoverable { get; init; } = true;
|
||||
@@ -75,6 +76,7 @@ namespace PrivaPub.Federation.Actors
|
||||
Followers = RemoteActorService.Text(root, "followers"),
|
||||
Following = RemoteActorService.Text(root, "following"),
|
||||
Featured = RemoteActorService.Text(root, "featured"),
|
||||
Wall = RemoteActorService.Text(root, "wall") ?? RemoteActorService.Text(root, "sm:wall"),
|
||||
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
|
||||
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
|
||||
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
|
||||
|
||||
@@ -46,6 +46,8 @@ namespace PrivaPub.Federation.Actors
|
||||
public string Following => $"{Uri}/stalking";
|
||||
public string Featured => $"{Uri}/trophies";
|
||||
public string FeaturedTags => $"{Uri}/tattoos";
|
||||
public string Wall => $"{Uri}/graffiti";
|
||||
public bool HasWall => Kind == LocalActorKind.Person && !IsCircle && IsFederated;
|
||||
public string Flock => $"{Uri}/flock";
|
||||
public string Wardens => $"{Uri}/wardens";
|
||||
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
||||
|
||||
@@ -205,6 +205,7 @@ namespace PrivaPub.Federation.Actors
|
||||
.Modify(a => a.FollowersURL, actor.Followers)
|
||||
.Modify(a => a.FollowingURL, actor.Following)
|
||||
.Modify(a => a.FeaturedURL, Origin.Same(actor.Featured, actor.Id) ? actor.Featured : default)
|
||||
.Modify(a => a.WallURL, Origin.Same(actor.Wall, actor.Id) ? actor.Wall : default)
|
||||
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
|
||||
.Modify(a => a.PictureURL, actor.Icon)
|
||||
.Modify(a => a.ThumbnailURL, actor.Header)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Domain.Timelines;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Federation.Actors
|
||||
{
|
||||
public interface IWalls
|
||||
{
|
||||
Task<LocalActor> OwnerOf(string wallUri, CancellationToken token);
|
||||
Task<bool> MayWrite(LocalActor owner, ForeignAvatar author, CancellationToken token);
|
||||
Task Hosted(PostEntity post, LocalActor owner, CancellationToken token);
|
||||
Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token);
|
||||
Task<bool> Remove(LocalActor owner, PostEntity post, CancellationToken token);
|
||||
}
|
||||
|
||||
// Walls (FEP-400e, Smithereen's; owner decision 2026-10-06). A persona's actor names its wall (`…/graffiti`), on which
|
||||
// the accounts following it may write: their public post, sent with the wall as its `target`, is hosted here, shown to
|
||||
// the persona and its followers, and told to its followers' servers with Add{Note}. The persona may take it off again
|
||||
// (Remove, which Smithereen takes for a deletion, as it is on its walls). Another server's account that tells its
|
||||
// followers here of a post on its wall (Add) has it shown to them as on its wall.
|
||||
public class Walls : IWalls
|
||||
{
|
||||
public const string Path = "graffiti";
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IRemotePosts _remotePosts;
|
||||
readonly IDeliveryService _delivery;
|
||||
readonly IFanout _fanout;
|
||||
|
||||
public Walls(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IDeliveryService delivery, IFanout fanout)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_remotePosts = remotePosts;
|
||||
_delivery = delivery;
|
||||
_fanout = fanout;
|
||||
}
|
||||
|
||||
public async Task<LocalActor> OwnerOf(string wallUri, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(wallUri) || !wallUri.EndsWith("/" + Path, StringComparison.Ordinal))
|
||||
return default;
|
||||
var owner = await _localActors.FindByUri(wallUri[..^(Path.Length + 1)], token);
|
||||
return owner is { HasWall: true } && owner.Wall == wallUri ? owner : default;
|
||||
}
|
||||
|
||||
// its followers, as the actor says to Smithereen (wallPosting), unless the persona hides them
|
||||
public async Task<bool> MayWrite(LocalActor owner, ForeignAvatar author, CancellationToken token) =>
|
||||
await _dbEntities.Followers.Match(f => f.LocalActorId == owner.Id && f.LocalActorKind == owner.Kind && f.ActorURI == author.ActorURI && f.IsAccepted)
|
||||
.ExecuteAnyAsync(token)
|
||||
&& !(await Domain.Relationships.Hidden.RecipientsHiding(new[] { owner.Id }, author.ActorURI, token)).Contains(owner.Id);
|
||||
|
||||
public async Task Hosted(PostEntity post, LocalActor owner, CancellationToken token)
|
||||
{
|
||||
await Notifications.Add(owner.Id, NotificationType.Mention, post.AuthorAccountId, post.ActorURI, post.ID, token);
|
||||
var add = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(),
|
||||
["id"] = owner.ActivityUri($"wall-{post.ID}"),
|
||||
["type"] = "Add",
|
||||
["actor"] = owner.Uri,
|
||||
["object"] = post.ObjectURI,
|
||||
["target"] = new JsonObject { ["type"] = "OrderedCollection", ["id"] = owner.Wall, ["attributedTo"] = owner.Uri },
|
||||
["to"] = new JsonArray(ActivityPubRenderer.Public, owner.Followers, post.ActorURI)
|
||||
};
|
||||
await _delivery.EnqueueToFollowers(owner, add, token, await AuthorInbox(post, token));
|
||||
}
|
||||
|
||||
public async Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token)
|
||||
{
|
||||
var objectUri = Id(activity["object"]);
|
||||
if (objectUri == default)
|
||||
{
|
||||
Arrival.Drop("unparseable");
|
||||
return;
|
||||
}
|
||||
if (!add)
|
||||
{
|
||||
var walled = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.WallOwnerURI == owner.ActorURI).ExecuteFirstAsync(token);
|
||||
if (walled == default)
|
||||
{
|
||||
Arrival.Drop("unknown-object");
|
||||
return;
|
||||
}
|
||||
await RemoteDeletes.Remove(walled, objectUri, token);
|
||||
Arrival.Accept("unwalled");
|
||||
return;
|
||||
}
|
||||
|
||||
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token)
|
||||
?? await _remotePosts.StoreContext(objectUri, 0, token);
|
||||
if (post is not { IsFederatedCopy: true, ReblogOfPostId: null, Visibility: PostVisibility.Public or PostVisibility.Unlisted } || !string.IsNullOrEmpty(post.InReplyToURI))
|
||||
{
|
||||
Arrival.Drop("unknown-object");
|
||||
return;
|
||||
}
|
||||
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
|
||||
if (post.WallOwnerURI == owner.ActorURI)
|
||||
{
|
||||
Arrival.Drop("duplicate");
|
||||
return;
|
||||
}
|
||||
post.WallURI = owner.WallURL;
|
||||
post.WallOwnerURI = owner.ActorURI;
|
||||
post.WallOwnerAccountId = owner.ID;
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID)
|
||||
.Modify(p => p.WallURI, post.WallURI)
|
||||
.Modify(p => p.WallOwnerURI, post.WallOwnerURI)
|
||||
.Modify(p => p.WallOwnerAccountId, post.WallOwnerAccountId)
|
||||
.ExecuteAsync(token);
|
||||
await _fanout.Distribute(post, token);
|
||||
Arrival.Accept("walled");
|
||||
}
|
||||
|
||||
public async Task<bool> Remove(LocalActor owner, PostEntity post, CancellationToken token)
|
||||
{
|
||||
if (post == default || !owner.HasWall || post.WallURI != owner.Wall)
|
||||
return false;
|
||||
var remove = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(),
|
||||
["id"] = owner.ActivityUri($"unwall-{post.ID}"),
|
||||
["type"] = "Remove",
|
||||
["actor"] = owner.Uri,
|
||||
["object"] = post.ObjectURI,
|
||||
// (a plain id: Smithereen refuses a Remove whose target is an object)
|
||||
["target"] = owner.Wall,
|
||||
["to"] = new JsonArray(ActivityPubRenderer.Public, owner.Followers, post.ActorURI)
|
||||
};
|
||||
await _delivery.EnqueueToFollowers(owner, remove, token, await AuthorInbox(post, token));
|
||||
await RemoteDeletes.Remove(post, post.ObjectURI, token);
|
||||
return true;
|
||||
}
|
||||
|
||||
async Task<IEnumerable<string>> AuthorInbox(PostEntity post, CancellationToken token)
|
||||
{
|
||||
var author = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == post.ActorURI).ExecuteFirstAsync(token);
|
||||
var inbox = string.IsNullOrEmpty(author?.SharedInboxURL) ? author?.InboxURL : author.SharedInboxURL;
|
||||
return inbox == default ? Array.Empty<string>() : new[] { inbox };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,30 @@ namespace PrivaPub.Federation.Controllers
|
||||
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
|
||||
}
|
||||
|
||||
// a persona's wall (FEP-400e): its own public posts that start a thread, and what its followers wrote on it, newest
|
||||
// first, as links; Smithereen reads it when one of its accounts starts following the persona
|
||||
[HttpGet, Route("{actor}/" + Walls.Path)]
|
||||
public async Task<IActionResult> Wall(string actor, [FromQuery] bool page, [FromQuery(Name = "max_id")] string maxId, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local is not { HasWall: true })
|
||||
return NotFound();
|
||||
var wall = local.Wall;
|
||||
System.Linq.Expressions.Expression<Func<PostEntity, bool>> onWall = p => !p.DeletedAt.HasValue && !p.AuthorGone && !p.IsLocalOnly
|
||||
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted)
|
||||
&& (p.WallURI == wall
|
||||
|| p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null && p.AnsweringToPostId == null && p.InReplyToURI == null && p.GroupId == null);
|
||||
if (!page)
|
||||
return Activity(ActivityPubRenderer.OrderedCollection(wall, (int)await DB.Default.CountAsync(onWall, token), default, $"{wall}?page=true"));
|
||||
var query = _dbEntities.Posts.Match(onWall);
|
||||
if (!string.IsNullOrEmpty(maxId))
|
||||
query.Match(f => f.Lt(p => p.ID, maxId));
|
||||
var latest = await query.Sort(p => p.ID, Order.Descending).Limit(OutboxSize).ExecuteAsync(token);
|
||||
var pageId = string.IsNullOrEmpty(maxId) ? $"{wall}?page=true" : $"{wall}?page=true&max_id={maxId}";
|
||||
var next = latest.Count == OutboxSize ? $"{wall}?page=true&max_id={latest[^1].ID}" : default;
|
||||
return Activity(ActivityPubRenderer.OrderedCollectionPage(pageId, wall, latest.Select(p => (JsonNode)(p.ObjectURI ?? local.PostUri(p.ID))), next, default));
|
||||
}
|
||||
|
||||
// FEP-8fcf: the persona's followers on the server that signs the request, and nobody else's (FollowersSynchronization)
|
||||
[HttpGet, Route("{actor}/groupies/roll-call")]
|
||||
public async Task<IActionResult> RollCall(string actor, CancellationToken token)
|
||||
|
||||
@@ -42,11 +42,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
readonly IQuoteService _quotes;
|
||||
readonly IInteractionApprovals _approvals;
|
||||
readonly Relays.IRelays _relays;
|
||||
readonly IWalls _walls;
|
||||
|
||||
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
||||
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews, IQuoteService quotes,
|
||||
IInteractionApprovals approvals = default, Relays.IRelays relays = default)
|
||||
IInteractionApprovals approvals = default, Relays.IRelays relays = default, IWalls walls = default)
|
||||
{
|
||||
_walls = walls;
|
||||
_relays = relays;
|
||||
_approvals = approvals;
|
||||
_quotes = quotes;
|
||||
@@ -189,8 +191,17 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
// forwarded post)
|
||||
var relayed = !followed && visibility == PostVisibility.Public && _relays != default
|
||||
&& await _relays.Passes(Arrival.Current?.ForwardedBy, token);
|
||||
// written on a persona's wall (FEP-400e): public, not a reply, by an account following the persona, as its actor
|
||||
// tells Smithereen
|
||||
var wall = _walls == default || note.Wall == default ? default : await _walls.OwnerOf(note.Wall, token);
|
||||
if (wall != default && (visibility is not (PostVisibility.Public or PostVisibility.Unlisted) || !string.IsNullOrEmpty(note.InReplyTo)
|
||||
|| note.WallOwner != default && note.WallOwner != wall.Uri || !await _walls.MayWrite(wall, author, token)))
|
||||
{
|
||||
Arrival.Drop("wall-refused");
|
||||
return;
|
||||
}
|
||||
if (visibility == PostVisibility.Direct ? persons.Count == 0
|
||||
: group == default && persons.Count == 0 && !repliesToLocal && !followed && !repliesToFollowed && !inFollowedGroup && !relayed)
|
||||
: group == default && wall == default && persons.Count == 0 && !repliesToLocal && !followed && !repliesToFollowed && !inFollowedGroup && !relayed)
|
||||
{
|
||||
Arrival.Drop("not-addressed");
|
||||
return;
|
||||
@@ -212,6 +223,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
post.ActivityURI = Id(activity);
|
||||
post.GroupId = group?.Id;
|
||||
post.ConversationId = conversation?.ID;
|
||||
if (wall != default)
|
||||
{
|
||||
post.WallURI = wall.Wall;
|
||||
post.WallOwnerURI = wall.Uri;
|
||||
post.WallOwnerAccountId = wall.Id;
|
||||
}
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
@@ -235,6 +252,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
await Domain.Statuses.ConversationStates.Posted(conversation.ID, post.ID, default, token);
|
||||
if (group is { IsCircle: false })
|
||||
await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: true, token);
|
||||
if (wall != default)
|
||||
await _walls.Hosted(post, wall, token);
|
||||
}
|
||||
|
||||
async Task<bool> IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) =>
|
||||
|
||||
@@ -6,21 +6,38 @@ using System.Text.Json.Nodes;
|
||||
namespace PrivaPub.Federation.Inbox.Handlers
|
||||
{
|
||||
// An account pins one of its posts (Add to its `featured` collection) or unpins it (Remove), as Mastodon and Akkoma
|
||||
// send it; inside a community's announce, the community features a post made in it. Any other target (Smithereen's
|
||||
// wall, a community's moderators) is not ours to keep.
|
||||
// send it; inside a community's announce, the community features a post made in it. On its wall (Smithereen's, FEP-400e)
|
||||
// it tells of a post someone wrote there, or takes it off. Any other target (a community's moderators) is not ours to
|
||||
// keep.
|
||||
public class FeaturedHandler : IActivityHandler
|
||||
{
|
||||
readonly IFeaturedPosts _featured;
|
||||
readonly IWalls _walls;
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly bool _add;
|
||||
|
||||
public FeaturedHandler(IFeaturedPosts featured, bool add)
|
||||
public FeaturedHandler(IFeaturedPosts featured, bool add, IWalls walls = default, IRemoteActorService remoteActors = default)
|
||||
{
|
||||
_featured = featured;
|
||||
_walls = walls;
|
||||
_remoteActors = remoteActors;
|
||||
_add = add;
|
||||
}
|
||||
|
||||
public string Type => _add ? "Add" : "Remove";
|
||||
|
||||
public Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) => _featured.Receive(activity, actor, _add, token);
|
||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var target = Objects.ActivityJson.Id(activity["target"]);
|
||||
// a collection of its own we do not know of: its document read again, at most hourly, in case it was kept before
|
||||
// we read that collection (a wall)
|
||||
if (_remoteActors != default && target != default && target != actor.FeaturedURL && target != actor.WallURL
|
||||
&& Objects.Origin.Same(target, actor.ActorURI) && actor.UpdatedAt < DateTime.UtcNow.AddHours(-1))
|
||||
actor = await _remoteActors.GetActor(actor.ActorURI, refresh: true, token) ?? actor;
|
||||
if (_walls != default && !string.IsNullOrEmpty(actor.WallURL) && target == actor.WallURL)
|
||||
await _walls.Receive(activity, actor, _add, token);
|
||||
else
|
||||
await _featured.Receive(activity, actor, _add, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ namespace PrivaPub.Federation.Objects
|
||||
public string InReplyTo { get; init; }
|
||||
public string Context { get; init; }
|
||||
public string Audience { get; init; }
|
||||
public string Wall { get; init; }//FEP-400e: the collection it says it was written in (`target`), a wall
|
||||
public string WallOwner { get; init; }//that collection's attributedTo
|
||||
public string QuoteUri { get; init; }
|
||||
public string ReplyAuthorization { get; init; }//the parent author's ReplyAuthorization (approvedBy before GoToSocial 0.21)
|
||||
public string QuoteAuthorization { get; init; }
|
||||
@@ -89,6 +91,8 @@ namespace PrivaPub.Federation.Objects
|
||||
InReplyTo = Id(note["inReplyTo"]),
|
||||
Context = Id(note["context"]) ?? Value(note, "conversation"),
|
||||
Audience = Id(note["audience"]),
|
||||
Wall = note["target"] is JsonObject target && Value(target, "type") is "Collection" or "OrderedCollection" ? Id(target) : default,
|
||||
WallOwner = note["target"] is JsonObject wallTarget ? Id(wallTarget["attributedTo"]) : default,
|
||||
QuoteUri = Id(note["quote"]) ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote") ?? QuoteLink(note),
|
||||
ReplyAuthorization = Id(note["replyAuthorization"]) ?? Id(note["approvedBy"]),
|
||||
QuoteAuthorization = Id(note["quoteAuthorization"]),
|
||||
|
||||
@@ -72,7 +72,14 @@ namespace PrivaPub.Federation.Rendering
|
||||
["value"] = "schema:value",
|
||||
["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger",
|
||||
["lemmy"] = "https://join-lemmy.org/ns#",
|
||||
["postingRestrictedToMods"] = "lemmy:postingRestrictedToMods"
|
||||
["postingRestrictedToMods"] = "lemmy:postingRestrictedToMods",
|
||||
// Smithereen's, which it compacts what it reads by: an allowedTo stays a plain string, as it reads them
|
||||
["sm"] = "http://smithereen.software/ns#",
|
||||
["wall"] = new JsonObject { ["@id"] = "sm:wall", ["@type"] = "@id" },
|
||||
["privacySettings"] = "sm:privacySettings",
|
||||
["wallPosting"] = "sm:wallPosting",
|
||||
["wallPostVisibility"] = "sm:wallPostVisibility",
|
||||
["allowedTo"] = "sm:allowedTo"
|
||||
});
|
||||
|
||||
public static string Html(string markdown) =>
|
||||
@@ -128,6 +135,16 @@ namespace PrivaPub.Federation.Rendering
|
||||
["value"] = FieldValue(field.Value)
|
||||
}).ToArray())
|
||||
};
|
||||
// a persona's wall (FEP-400e, owner decision 2026-10-06): its followers write on it, everyone reads it
|
||||
if (actor.HasWall)
|
||||
{
|
||||
document["wall"] = actor.Wall;
|
||||
document["privacySettings"] = new JsonObject
|
||||
{
|
||||
["wallPosting"] = new JsonObject { ["allowedTo"] = new JsonArray(actor.Followers) },
|
||||
["wallPostVisibility"] = new JsonObject { ["allowedTo"] = new JsonArray(Public) }
|
||||
};
|
||||
}
|
||||
if (actor is { Kind: LocalActorKind.Group, IsCircle: false })
|
||||
{
|
||||
document["attributedTo"] = actor.Wardens;
|
||||
|
||||
Reference in new issue
Block a user