P6: custom emoji, fuller remote profiles, and polls both ways
Build / Build (push) Successful in 39s
Deploy / privapub.thepra.dev (push) Successful in 57s

- Custom emoji (Emoji tags) on posts, display names, bios and profile fields, at most 64 per object, proxied, in
  Status.emojis and Account.emojis.
- Remote profiles keep their header, profile fields, locked flag, published date, movedTo, indexable, memorial and
  image descriptions (a locked GoToSocial account no longer shows as open).
- Polls: incoming Questions (Mastodon, Misskey, Pleroma, GoToSocial shapes) with counts, voters, end and closed;
  our own polls from the Mastodon API go out as Questions; votes in are counted once per voter and never become
  replies; personas vote on other servers' polls with one Note per choice; counts refresh with an Update at most every
  three minutes; a poll closes on time and tells its voters and its author. GET /api/v1/polls/:id and POST
  /api/v1/polls/:id/votes.
- An Update without a newer `updated` only refreshes poll, video, audio and event details and leaves no revision.

Checked live against GoToSocial: each side's poll reaches the other as a poll and each side's vote is counted.

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 18:35:20 +02:00
1 parent 81de470f64
commit 7be6749a23
33 files changed
+937 -28

No files matched your search

+50 -1
View File
@@ -1,6 +1,9 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
namespace PrivaPub.Federation.Actors
{
@@ -23,6 +26,16 @@ namespace PrivaPub.Federation.Actors
public string SharedInbox { get; init; }
public string Icon { get; init; }
public bool Discoverable { get; init; } = true;
public bool Indexable { get; init; }
public bool Locked { get; init; }
public bool Memorial { get; init; }
public string Header { get; init; }
public string IconDescription { get; init; }
public string HeaderDescription { get; init; }
public string MovedTo { get; init; }
public DateTime? Published { get; init; }
public List<CustomEmoji> Emojis { get; init; } = new();
public Dictionary<string, string> Fields { get; init; } = new();
public IReadOnlyList<ActorKey> Keys { get; init; } = Array.Empty<ActorKey>();
public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId);
@@ -52,10 +65,46 @@ namespace PrivaPub.Federation.Actors
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,
Keys = ParseKeys(root, id)
Keys = ParseKeys(root, id),
Indexable = Flag(root, "indexable"),
Locked = Flag(root, "manuallyApprovesFollowers"),
Memorial = Flag(root, "memorial"),
Header = root.TryGetProperty("image", out var image) ? ObjectShapes.Image(JsonNode.Parse(image.GetRawText())) : default,
IconDescription = root.TryGetProperty("icon", out var described) ? Alt(described) : default,
HeaderDescription = root.TryGetProperty("image", out var headerImage) ? Alt(headerImage) : default,
MovedTo = RemoteActorService.Text(root, "movedTo"),
Published = DateTimeOffset.TryParse(RemoteActorService.Text(root, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published)
? published.UtcDateTime
: default(DateTime?),
Emojis = root.TryGetProperty("tag", out var tags) ? ObjectShapes.Emojis(JsonNode.Parse(tags.GetRawText())) : new(),
Fields = root.TryGetProperty("attachment", out var attachment) ? ParseFields(JsonNode.Parse(attachment.GetRawText())) : new()
};
}
const int MaxFields = 16;
static bool Flag(JsonElement root, string property) =>
root.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.True;
static string Alt(JsonElement image) =>
image.ValueKind == JsonValueKind.Object ? ObjectShapes.Text(RemoteActorService.Text(image, "name") ?? RemoteActorService.Text(image, "summary"), 1500) : default;
static Dictionary<string, string> ParseFields(JsonNode attachment)
{
var fields = new Dictionary<string, string>();
foreach (var field in ObjectShapes.Objects(attachment).Where(a => ActivityJson.Value(a, "type") is "PropertyValue" or "Note"))
{
var name = ObjectShapes.Text(ActivityJson.Value(field, "name"), 255);
var value = ActivityJson.Value(field, "value") ?? ActivityJson.Value(field, "content");
if (name == default || value == default || fields.ContainsKey(name))
continue;
fields[name] = ContentSanitizer.Html(value.Length <= 2048 ? value : value[..2048]);
if (fields.Count == MaxFields)
break;
}
return fields;
}
static List<ActorKey> ParseKeys(JsonElement root, string actorId)
{
var keys = new List<ActorKey>();
@@ -177,7 +177,17 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.FollowingURL, actor.Following)
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
.Modify(a => a.PictureURL, actor.Icon)
.Modify(a => a.ThumbnailURL, actor.Header)
.Modify(a => a.PictureDescription, actor.IconDescription)
.Modify(a => a.HeaderDescription, actor.HeaderDescription)
.Modify(a => a.IsDiscoverable, actor.Discoverable)
.Modify(a => a.IsIndexable, actor.Indexable)
.Modify(a => a.IsLocked, actor.Locked)
.Modify(a => a.IsMemorial, actor.Memorial)
.Modify(a => a.MovedToURL, Origin.Of(actor.MovedTo) == default ? default : actor.MovedTo)
.Modify(a => a.Published, actor.Published)
.Modify(a => a.Emojis, actor.Emojis)
.Modify(a => a.Fields, actor.Fields)
.Modify(a => a.AvatarType, Enum.TryParse<AvatarType>(actor.Type, out var type) ? type : AvatarType.Person)
.Modify(a => a.PublicKeyId, key?.Id)
.Modify(a => a.PublicKey, key?.Pem)
@@ -4,6 +4,7 @@ using MongoDB.Entities;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Moderation;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Ids;
@@ -35,11 +36,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IRemotePosts _remotePosts;
readonly IGroupDistributor _groups;
readonly IObjectRecords _records;
readonly IPollService _polls;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records)
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls)
{
_records = records;
_polls = polls;
_groups = groups;
_fanout = fanout;
_remotePosts = remotePosts;
@@ -64,6 +67,15 @@ namespace PrivaPub.Federation.Inbox.Handlers
var note = NoteParser.Parse(node);
if (note == default || note.AttributedTo != author.ActorURI)
return;
if (note.Title != default && note.InReplyTo != default && ObjectShapes.Text(note.ContentHtml, 1) == default)
{
var question = await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && p.Poll != null).ExecuteFirstAsync(token);
if (question != default)
{
await _polls.Receive(question, author, note.Title, Id(activity), token);
return;
}
}
if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token)
|| await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token))
return;
@@ -55,6 +55,17 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (post == default || post.DeletedAt.HasValue)
return;
post.Poll = note.Poll ?? post.Poll;
post.Video = note.Video ?? post.Video;
post.Audio = note.Audio ?? post.Audio;
post.Event = note.Event ?? post.Event;
if (!IsEdit(note, post))
{
await DB.Default.SaveAsync(post, token);
await _records.Revise(note, Id(activity), token);
return;
}
post.Revisions.Add(new PostRevision
{
Title = post.Title,
@@ -77,11 +88,9 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.SpoilerText = note.SpoilerText;
post.Excerpt = note.Excerpt;
post.Source = note.Source;
post.Emojis = note.Emojis.ToList();
post.CoverURL = note.CoverURL;
post.Link = note.Link;
post.Video = note.Video;
post.Audio = note.Audio;
post.Event = note.Event;
post.HasContentWarning = note.Sensitive;
post.Text = note.ContentHtml;
post.ContentHtml = note.ContentHtml;
@@ -96,5 +105,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
}
static bool IsEdit(NoteDocument note, PostEntity post) =>
note.Updated is { } updated && updated > (post.EditedAt ?? post.CreationDate);
}
}
+2
View File
@@ -78,6 +78,8 @@ namespace PrivaPub.Federation.Inbox
SpoilerText = note.SpoilerText,
Excerpt = note.Excerpt,
Source = note.Source,
Poll = note.Poll,
Emojis = note.Emojis.ToList(),
CoverURL = note.CoverURL,
Link = note.Link,
Video = note.Video,
@@ -33,6 +33,8 @@ namespace PrivaPub.Federation.Objects
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
public PostSource Source { get; init; }
public PostPoll Poll { get; init; }
public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>();
public string CoverURL { get; init; }
public PostLink Link { get; init; }
public VideoDetails Video { get; init; }
@@ -94,6 +96,8 @@ namespace PrivaPub.Federation.Objects
.ToList(),
Attachments = Attachments(note),
Source = ObjectShapes.Source(note),
Poll = ObjectShapes.Poll(note),
Emojis = ObjectShapes.Emojis(note["tag"]),
CoverURL = ObjectShapes.Cover(note),
Link = ObjectShapes.Link(note),
Video = ObjectShapes.Video(note),
@@ -16,6 +16,8 @@ namespace PrivaPub.Federation.Objects
const int MaxCaptions = 20;
const int MaxPlaces = 4;
const int CoverMaxWidth = 1280;
const int MaxEmojis = 64;
const int MaxPollOptions = 20;
static readonly Dictionary<string, string> MediaTypesByExtension = new(StringComparer.OrdinalIgnoreCase)
{
@@ -81,6 +83,52 @@ namespace PrivaPub.Federation.Objects
?? usable.Select(c => c.Url).FirstOrDefault();
}
public static List<CustomEmoji> Emojis(JsonNode tags) =>
Objects(tags)
.Where(t => Value(t, "type") == "Emoji")
.Select(t => new CustomEmoji
{
Shortcode = Value(t, "name")?.Trim(':'),
URL = MediaLink(t["icon"]).Url,
Updated = Moment(Value(t, "updated"))
})
.Where(e => !string.IsNullOrEmpty(e.Shortcode) && e.Shortcode.Length <= 100 && Shortcode().IsMatch(e.Shortcode) && Origin.Of(e.URL) != default)
.DistinctBy(e => e.Shortcode)
.Take(MaxEmojis)
.ToList();
public static PostPoll Poll(JsonObject note)
{
if (Value(note, "type") != "Question")
return default;
var multiple = note["anyOf"] is JsonArray;
var options = Objects(note[multiple ? "anyOf" : "oneOf"])
.Select(o => new PollOption
{
Title = Text(Value(o, "name"), 200),
Votes = o["replies"] is JsonObject replies && Int(replies, "totalItems") is { } total ? total : Int(o, "_misskey_votes") ?? 0
})
.Where(o => o.Title != default)
.Take(MaxPollOptions)
.ToList();
if (options.Count < 2)
return default;
var closedAt = note["closed"] switch
{
JsonValue value when value.TryGetValue<bool>(out var closed) => closed ? Moment(Value(note, "endTime")) ?? DateTime.UtcNow : default(DateTime?),
JsonValue value when value.TryGetValue<string>(out var at) => Moment(at),
_ => default
};
return new PostPoll
{
Options = options,
Multiple = multiple,
ExpiresAt = Moment(Value(note, "endTime")),
ClosedAt = closedAt,
VotersCount = Int(note, "votersCount")
};
}
public static string Cover(JsonObject note) =>
Image(note["image"]) ?? Image(note["icon"]) ?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Image" ? Image(preview) : default);
@@ -284,6 +332,9 @@ namespace PrivaPub.Federation.Objects
static bool? Bool(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<bool>(out var flag) ? flag : (bool?)null;
[GeneratedRegex(@"^[\w+\-.@]+$")]
private static partial Regex Shortcode();
[GeneratedRegex("<[^>]*>")]
private static partial Regex Tags();
@@ -37,6 +37,7 @@ namespace PrivaPub.Federation.Rendering
["discoverable"] = "toot:discoverable",
["indexable"] = "toot:indexable",
["blurhash"] = "toot:blurhash",
["votersCount"] = "toot:votersCount",
["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" },
["schema"] = "http://schema.org#",
["PropertyValue"] = "schema:PropertyValue",
@@ -156,6 +157,23 @@ namespace PrivaPub.Federation.Rendering
return note;
}
static void AddPoll(JsonObject note, PostPoll poll)
{
note["type"] = "Question";
note[poll.Multiple ? "anyOf" : "oneOf"] = new JsonArray(poll.Options.Select(option => (JsonNode)new JsonObject
{
["type"] = "Note",
["name"] = option.Title,
["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = option.Votes }
}).ToArray());
if (poll.ExpiresAt is { } ends)
note["endTime"] = Timestamp(ends);
if (poll.ClosedAt is { } closed)
note["closed"] = Timestamp(closed);
if (poll.VotersCount is { } voters)
note["votersCount"] = voters;
}
static string Headline(PostEntity post)
{
var text = System.Text.RegularExpressions.Regex.Replace(post.ContentHtml ?? post.Text ?? string.Empty, "<[^>]+>", " ");
@@ -192,6 +210,8 @@ namespace PrivaPub.Federation.Rendering
};
if (!string.IsNullOrEmpty(post.Language))
note["contentMap"] = new JsonObject { [post.Language] = content };
if (post.Poll is { } poll)
AddPoll(note, poll);
var attachments = post.Media.Where(m => !string.IsNullOrEmpty(m.URL)).Select(m =>
{
var attachment = new JsonObject