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:
1 parent
796f06acf4
commit
e9529310fe
21 files changed
+777
-192
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user