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>();