One Post model for every visibility, read from notes the way peers write them

Post gains what federation and the Mastodon API need: Visibility (Public,
Unlisted, FollowersOnly, Direct, Circle, LocalGeo), the author's account
id, to/cc, the Create's id, url, context, quote, InReplyToURI and the
parent's author, a separate SpoilerText next to the title, language,
mentions, hashtags, remote attachments (alt text, blurhash, focus, size),
reply/favourite/reblog counters, revisions, EditedAt and DeletedAt.

Direct messages are Posts with Visibility Direct and a ConversationId;
migration _005 copies DmPost rows across with their ids and fills the new
fields of existing posts. DmPost is left in place so a rollback still sees
the old messages.

Inbound:
- NoteParser reads Note, Article, Page, Question and media types: content,
  then contentMap, then _misskey_content; summary as the spoiler and name
  as the title; Mention and Hashtag tags; attachments; a PeerTube-style
  list attribution prefers the person over the channel; quote URIs.
- Addressing classifies like Mastodon, finding followers-only by the
  author's own followers URL (now stored on ForeignAvatar), not by a
  "/followers" suffix.
- Create keeps a post when a local persona is addressed or mentioned, when
  it replies to a local post (the parent's reply count goes up) or when a
  community it follows is addressed; an unsolicited public post is not
  stored. Update keeps the previous version as a revision.

The outbox and object endpoints serve only Public and Unlisted posts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 11:17:14 +02:00
1 parent 796f06acf4
commit e9529310fe
21 files changed
+777 -192

No files matched your search

+61 -39
View File
@@ -12,7 +12,6 @@ using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using DmPostEntity = PrivaPub.Models.Post.DmPost;
using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
@@ -76,32 +75,44 @@ namespace PrivaPub.Services
isLocalOnly = !group.IsFederated;
}
var parent = await Parent(form.AnsweringToPostId, token);
var post = new PostEntity
{
GroupUserId = author.Id,
AuthorAccountId = author.Id,
GroupId = group?.Id,
Visibility = isLocalOnly ? PostVisibility.Circle : PostVisibility.Public,
Title = form.Title,
Text = form.Text,
ContentHtml = ActivityPubRenderer.Html(form.Text),
ContentFormat = ContentFormat.Markdown,
HasContentWarning = form.HasContentWarning,
AnsweringToPostId = form.AnsweringToPostId,
AnsweringToPostId = parent?.ID,
InReplyToURI = parent?.ObjectURI ?? RemoteUri(form.AnsweringToPostId),
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
IsLocalOnly = isLocalOnly,
ActorURI = author.Uri
};
post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID);
await DB.Default.SaveAsync(post, token);
post.Url = post.ObjectURI;
if (isLocalOnly)
{
await DB.Default.SaveAsync(post, token);
result.Data = ToView(post);
return result;
}
var inReplyTo = await ReplyTarget(form.AnsweringToPostId, token);
var note = ActivityPubRenderer.Note(post, author, group, inReplyTo);
var note = ActivityPubRenderer.Note(post, author, group, post.InReplyToURI);
var create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
post.ActivityURI = create["id"]!.GetValue<string>();
post.To = Strings(note["to"]);
post.Cc = Strings(note["cc"]);
await DB.Default.SaveAsync(post, token);
if (parent != default)
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
await _delivery.EnqueueToFollowers(author, create, token);
if (group != default)
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
@@ -135,6 +146,21 @@ namespace PrivaPub.Services
if (post.IsLocalOnly)
return result;
if (post.Visibility == PostVisibility.Direct)
{
var recipients = new List<string>();
foreach (var uri in post.To.Concat(post.Cc).Distinct())
{
var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
if (foreign != default)
recipients.Add(foreign.InboxURL);
}
var directDelete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray());
await _delivery.Enqueue(author, recipients, directDelete, token);
return result;
}
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers));
var extraInboxes = Enumerable.Empty<string>();
@@ -165,7 +191,7 @@ namespace PrivaPub.Services
var query = _dbEntities.Posts;
if (string.IsNullOrEmpty(groupId))
query.Match(p => p.GroupUserId == avatar.Id);
query.Match(p => p.GroupUserId == avatar.Id && p.Visibility != PostVisibility.Direct);
else
{
var isMember = await _dbEntities.Groups
@@ -227,10 +253,13 @@ namespace PrivaPub.Services
}
}
var dm = new DmPostEntity
var dm = new PostEntity
{
GroupUserId = author.Id,
GroupId = dmGroup.ID,
AuthorAccountId = author.Id,
ConversationId = dmGroup.ID,
Visibility = PostVisibility.Direct,
ContextURI = dmGroup.ConversationURI,
Text = form.Text,
ContentHtml = ActivityPubRenderer.Html(form.Text),
ContentFormat = ContentFormat.Markdown,
@@ -239,8 +268,7 @@ namespace PrivaPub.Services
};
dm.ID = (string)dm.GenerateNewID();
dm.ObjectURI = author.PostUri(dm.ID);
await DB.Default.SaveAsync(dm, token);
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
dm.Url = dm.ObjectURI;
var remote = new List<(string Uri, string Handle)>();
var inboxes = new List<string>();
@@ -259,12 +287,15 @@ namespace PrivaPub.Services
remote.Add((local.Uri, local.Handle));
}
var directNote = ActivityPubRenderer.DirectNote(dm, author, remote, dmGroup.ConversationURI);
var directCreate = ActivityPubRenderer.Create(author, directNote, $"create-{dm.ID}");
dm.ActivityURI = directCreate["id"]!.GetValue<string>();
dm.To = Strings(directNote["to"]);
dm.Mentions = remote.Select(r => new PostMention { ActorURI = r.Uri, Handle = "@" + r.Handle, IsLocal = r.Uri.StartsWith(author.BaseAddress + "/") }).ToList();
await DB.Default.SaveAsync(dm, token);
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
if (inboxes.Count > 0)
{
var note = ActivityPubRenderer.DirectNote(dm, author, remote, dmGroup.ConversationURI);
var create = ActivityPubRenderer.Create(author, note, $"create-{dm.ID}");
await _delivery.Enqueue(author, inboxes, create, token);
}
await _delivery.Enqueue(author, inboxes, directCreate, token);
result.Data = ToView(dm);
return result;
@@ -306,8 +337,8 @@ namespace PrivaPub.Services
if (!isMember)
return result.Invalidate(_localizer["Conversation not found."], StatusCodes.Status404NotFound);
var dms = await _dbEntities.DmPosts
.Match(p => p.GroupId == dmGroupId)
var dms = await _dbEntities.Posts
.Match(p => p.ConversationId == dmGroupId && p.Visibility == PostVisibility.Direct)
.Sort(p => p.CreationDate, Order.Descending)
.Limit(PageSize)
.ExecuteAsync(token);
@@ -337,16 +368,21 @@ namespace PrivaPub.Services
return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true };
}
async Task<string> ReplyTarget(string answeringToPostId, CancellationToken token)
async Task<PostEntity> Parent(string answeringTo, CancellationToken token)
{
if (string.IsNullOrEmpty(answeringToPostId))
if (string.IsNullOrEmpty(answeringTo))
return default;
if (answeringToPostId.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return answeringToPostId;
var parent = await _dbEntities.Posts.MatchID(answeringToPostId).ExecuteFirstAsync(token);
return parent?.ObjectURI;
return answeringTo.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
? await _dbEntities.Posts.Match(p => p.ObjectURI == answeringTo).ExecuteFirstAsync(token)
: await _dbEntities.Posts.MatchID(answeringTo).ExecuteFirstAsync(token);
}
static string RemoteUri(string answeringTo) =>
answeringTo != default && answeringTo.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? answeringTo : default;
static List<string> Strings(JsonNode node) =>
node is JsonArray array ? array.Select(n => n?.GetValue<string>()).Where(s => s != default).ToList() : new List<string>();
async Task<LocalActor> OwnedAvatar(string rootUserId, string avatarId, CancellationToken token)
{
if (string.IsNullOrEmpty(rootUserId) || string.IsNullOrEmpty(avatarId))
@@ -363,22 +399,8 @@ namespace PrivaPub.Services
AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId,
AuthorActorURI = post.ActorURI,
GroupId = post.GroupId,
AnsweringToPostId = post.AnsweringToPostId,
Title = post.Title,
Text = post.Text,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning,
IsFederatedCopy = post.IsFederatedCopy,
CreationDate = post.CreationDate
};
static ViewPost ToView(DmPostEntity post) => new()
{
Id = post.ID,
ObjectURI = post.ObjectURI,
AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId,
AuthorActorURI = post.ActorURI,
DmGroupId = post.GroupId,
DmGroupId = post.ConversationId,
Visibility = post.Visibility.ToString(),
AnsweringToPostId = post.AnsweringToPostId,
Title = post.Title,
Text = post.Text,