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:
thepraandClaude Opus 5.5 committed 2026-10-06 08:17:10 +02:00
1 parent e0682fa868
commit c47b6e5533
26 files changed
+582 -36

No files matched your search

@@ -34,10 +34,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
readonly Domain.Media.IMediaService _media;
readonly IParticipations _participations;
readonly Federation.Actors.IWalls _walls;
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes,
IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default)
IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default,
Federation.Actors.IWalls walls = default)
{
_walls = walls;
_participations = participations;
_jobs = jobs;
_media = media;
@@ -161,7 +164,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
{
var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (before == default)
return NotFoundError();
return await OffWall(id, token);
var status = await _mapper.Status(before, MyId, token);
var outcome = await _statuses.Remove(Me, id, token);
if (!outcome.Ok)
@@ -174,6 +177,20 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(status);
}
// someone's post on the persona's wall (FEP-400e), taken off it: as on Smithereen, the owner of a wall deletes what is on it
async Task<IActionResult> OffWall(string id, CancellationToken token)
{
var walled = _walls == default
? default
: await _dbEntities.Posts.Match(p => p.ID == id && p.WallOwnerAccountId == MyId && p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (walled == default)
return NotFoundError();
var status = await _mapper.Status(walled, MyId, token);
if (!await _walls.Remove(Me, walled, token))
return NotFoundError();
return Json(status);
}
[HttpGet("/api/v1/statuses/{id}/context"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Context(string id, CancellationToken token)
{
@@ -77,6 +77,7 @@
public PrivaPubAudio Audio { get; set; }
public PrivaPubEvent Event { get; set; }
public PrivaPubPlace Place { get; set; }//a remote post's own place (Pixelfed), shown only
public Account Wall { get; set; }//whose wall it was written on (FEP-400e), when not its author's
public PrivaPubVotes Votes { get; set; } = new();
}
@@ -203,7 +203,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
var mentionAccounts = mentionUris.Count == 0
? new Dictionary<string, ForeignAvatar>()
: (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI);
var accounts = await Accounts(all.Select(AuthorOf), token);
var accounts = await Accounts(all.Select(AuthorOf).Concat(all.Select(p => p.WallOwnerAccountId).Where(w => w != default)), token);
var ids = all.Select(p => p.ID).ToList();
var favourited = viewerId == default
@@ -277,7 +277,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
Card = Card(post),
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
Emojis = Emojis(post.Emojis),
Privapub = Extension(post),
Privapub = Extension(post, accounts),
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
Mentions = post.Mentions.Where(m => !m.Silent).Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
@@ -480,8 +480,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new();
PrivaPubStatus Extension(PostEntity post) => new()
PrivaPubStatus Extension(PostEntity post, Dictionary<string, Account> accounts) => new()
{
Wall = post.WallOwnerAccountId == default || post.WallOwnerAccountId == AuthorOf(post) ? default : accounts.GetValueOrDefault(post.WallOwnerAccountId),
ObjectType = post.ObjectType,
Locked = post.LockedAt.HasValue,
Approval = post.Approval is ApprovalState.Pending or ApprovalState.Rejected ? post.Approval.ToString().ToLowerInvariant() : default,
+8
View File
@@ -43,6 +43,14 @@ namespace PrivaPub.Domain.Timelines
if (!string.IsNullOrEmpty(post.AudienceURI))
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted).ExecuteAsync(token))
recipients.Add(member.AvatarId);
// a post on a wall (FEP-400e): its owner, when a persona, and those following the owner
if (!string.IsNullOrEmpty(post.WallOwnerURI))
{
if (await _dbEntities.Avatars.MatchID(post.WallOwnerAccountId).ExecuteAnyAsync(token))
recipients.Add(post.WallOwnerAccountId);
foreach (var follower in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.WallOwnerURI && f.State == FollowState.Accepted).ExecuteAsync(token))
recipients.Add(follower.AvatarId);
}
// a public post (not a boost, not a reply) comes to those following one of its hashtags, as on Mastodon
if (post.Visibility == PostVisibility.Public && post.Tags.Count > 0 && string.IsNullOrEmpty(post.ReblogOfPostId)
&& string.IsNullOrEmpty(post.InReplyToURI) && string.IsNullOrEmpty(post.AnsweringToPostId))
@@ -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)
+157
View File
@@ -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;
+1
View File
@@ -22,6 +22,7 @@ namespace PrivaPub.Infrastructure.Data
await Plain<Post>(token, p => p.GroupUserId, p => p.ID);
await Plain<Post>(token, p => p.GroupId, p => p.ID);
await Plain<Post>(token, p => p.ActorURI);
await Plain<Post>(token, p => p.WallURI, p => p.ID);//a persona's wall (FEP-400e)
await Plain<Post>(token, p => p.ConversationId, p => p.ID);
await Plain<Post>(token, p => p.InReplyToURI);
// who boosted what: a persona's boosts of the posts a page shows were a scan of every post under load
@@ -95,8 +95,11 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, BlockHandler>()
.AddSingleton<IActivityHandler, LockHandler>()
.AddSingleton<Federation.Actors.IFeaturedPosts, Federation.Actors.FeaturedPosts>()
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: true))
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: false))
.AddSingleton<Federation.Actors.IWalls, Federation.Actors.Walls>()
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: true,
services.GetRequiredService<Federation.Actors.IWalls>(), services.GetRequiredService<Federation.Actors.IRemoteActorService>()))
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: false,
services.GetRequiredService<Federation.Actors.IWalls>(), services.GetRequiredService<Federation.Actors.IRemoteActorService>()))
.AddSingleton<IActivityHandler, MoveHandler>()
.AddSingleton<IActivityHandler, CreateHandler>()
.AddSingleton<IActivityHandler, DeleteHandler>()
+3
View File
@@ -14,6 +14,9 @@ namespace PrivaPub.Models.Post
public string GroupId { get; set; }
public string ConversationId { get; set; }//DmGroup.ID of a direct post
public string AudienceURI { get; set; }//a remote community the post was made in
public string WallURI { get; set; }//the wall it was written on (FEP-400e): a persona's here, or another account's elsewhere
public string WallOwnerURI { get; set; }//that wall's owner, whose followers it reaches
public string WallOwnerAccountId { get; set; }//the owner's Avatar.ID or ForeignAvatar.ID
public string ReblogOfPostId { get; set; }
public PostVisibility Visibility { get; set; }
public string Title { get; set; }
+1
View File
@@ -76,6 +76,7 @@ namespace PrivaPub.Models.User
public string InboxURL { get; set; }
public string OutboxURL { get; set; }
public string FeaturedURL { get; set; }//featured: the posts it pins
public string WallURL { get; set; }//sm:wall (FEP-400e): where others write to it, Smithereen's walls
public string MovedToURL { get; set; }
public List<string> AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is
public string PictureURL { get; set; }//icon