From 7be6749a230dade333f8cf8b0213eb0a477fe5f2 Mon Sep 17 00:00:00 2001 From: thepra Date: Thu, 1 Oct 2026 18:35:20 +0200 Subject: [PATCH] P6: custom emoji, fuller remote profiles, and polls both ways - 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 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- CLAUDE.md | 6 + FEDERATION.md | 14 +- .../Federation/ActorProfileTests.cs | 41 +++ .../Federation/InboxScenarioTests.cs | 3 +- .../Federation/ObjectShapesTests.cs | 45 +++ PrivaPub.Tests/Federation/PollTests.cs | 138 ++++++++ PrivaPub.Tests/Support/Harness.cs | 6 +- .../Mastodon/Controllers/PollsController.cs | 54 ++++ .../Controllers/StatusesController.cs | 9 +- .../Controllers/TimelinesController.cs | 3 +- PrivaPub/Api/Mastodon/Entities/Entities.cs | 40 ++- .../Api/Mastodon/Mappers/MastodonMapper.cs | 41 ++- PrivaPub/Domain/Social/Notifications.cs | 2 +- PrivaPub/Domain/Statuses/PollService.cs | 300 ++++++++++++++++++ PrivaPub/Domain/Statuses/StatusService.cs | 13 +- PrivaPub/Federation/Actors/ActorDocument.cs | 51 ++- .../Federation/Actors/RemoteActorService.cs | 10 + .../Inbox/Handlers/CreateHandler.cs | 14 +- .../Inbox/Handlers/UpdateHandler.cs | 18 +- PrivaPub/Federation/Inbox/RemotePosts.cs | 2 + PrivaPub/Federation/Objects/NoteParser.cs | 4 + PrivaPub/Federation/Objects/ObjectShapes.cs | 51 +++ .../Rendering/ActivityPubRenderer.cs | 20 ++ PrivaPub/Infrastructure/Data/Indexes.cs | 3 + .../Middleware/SocialPubConfigurations.cs | 3 + PrivaPub/Models/Jobs/Job.cs | 4 +- PrivaPub/Models/Post/Post.cs | 3 + PrivaPub/Models/Post/PostDetails.cs | 23 ++ PrivaPub/Models/Social/Notification.cs | 3 +- PrivaPub/Models/Social/PollVote.cs | 15 + PrivaPub/Models/User/Avatar.cs | 7 + docs/ROADMAP.md | 6 +- tools/pasture/interop.sh | 13 + 33 files changed, 937 insertions(+), 28 deletions(-) create mode 100644 PrivaPub.Tests/Federation/ActorProfileTests.cs create mode 100644 PrivaPub.Tests/Federation/PollTests.cs create mode 100644 PrivaPub/Api/Mastodon/Controllers/PollsController.cs create mode 100644 PrivaPub/Domain/Statuses/PollService.cs create mode 100644 PrivaPub/Models/Social/PollVote.cs diff --git a/CLAUDE.md b/CLAUDE.md index 35865e2..4ec20df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,6 +252,12 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ array; Markdown `content`; missing `mediaType`s inferred; thumbnails from any of their five places; and the typed `Link`, `Video`, `Audio` and `Event` details. A Mastodon API card is built from those, never by fetching the linked page (that fetch is the owner's decision 1, for P6). +- **An `Update` is an edit only when its `updated` is newer than ours** (`UpdateHandler.IsEdit`). Otherwise it refreshes + the poll, video, audio and event details and nothing else, and leaves no revision: Mastodon and Misskey refresh poll + counts with bare Updates. +- **A poll vote is a `Note` with a `name`, an `inReplyTo` that is a poll we hold, and no content.** `CreateHandler` + hands it to `PollService.Receive` before anything else, so it never becomes a reply. Our votes on other servers' polls + go only to the poll's author, without `published` (PieFed counts a vote only then). - **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything. - **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post diff --git a/FEDERATION.md b/FEDERATION.md index 229965b..8ea9ae7 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -108,7 +108,17 @@ Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}` - **Reports** are sent as `Flag` by the instance actor, never by the reporting account. A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag` -tags. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show +tags. + +**Polls** (FEP-9967) go out as a `Question` with `oneOf` or `anyOf`, each option's count in `replies.totalItems`, +`endTime`, `votersCount`, and `closed` once it has ended. Incoming votes are `Create{Note{name, inReplyTo}}` with no +content, one per choice, counted once per voter. Counts are refreshed with an `Update{Question}` at most every three +minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`. +An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit. + +**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object. +**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`, +`memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show `name`. A content warning without its own text uses the title, or "Content warning". Received `summary` is a content warning only on a `Note` or `Question`, or when `sensitive` is `true`. On an `Article`, @@ -142,7 +152,7 @@ Posts with a location (shown to nearby users of this server) never leave the ser ## Known limitations -- Polls and custom emoji are not implemented. +- Our own posts carry no custom emoji: emoji are received and shown, not offered. - Remote media is fetched through this server's proxy when a local client displays it. - Collections expose counts, not members. - Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned. diff --git a/PrivaPub.Tests/Federation/ActorProfileTests.cs b/PrivaPub.Tests/Federation/ActorProfileTests.cs new file mode 100644 index 0000000..8e987b7 --- /dev/null +++ b/PrivaPub.Tests/Federation/ActorProfileTests.cs @@ -0,0 +1,41 @@ +using PrivaPub.Federation.Actors; + +using System.Text.Json; + +namespace PrivaPub.Tests.Federation +{ + public class ActorProfileTests + { + [Fact] + public void Reads_a_mastodon_profile_with_its_header_fields_emoji_and_lock() + { + using var json = JsonDocument.Parse(""" + { + "id": "https://m.example/users/ann", "type": "Person", "preferredUsername": "ann", "name": "Ann :sparkle:", + "inbox": "https://m.example/users/ann/inbox", "manuallyApprovesFollowers": true, "indexable": true, "memorial": false, + "published": "2023-04-01T00:00:00Z", "movedTo": "https://n.example/users/ann", + "icon": { "type": "Image", "url": "https://m.example/a.png", "summary": "a cat" }, + "image": { "type": "Image", "url": "https://m.example/h.png", "name": "mountains" }, + "tag": [{ "type": "Emoji", "name": ":sparkle:", "icon": { "type": "Image", "url": "https://m.example/emoji/sparkle.png" } }], + "attachment": [ + { "type": "PropertyValue", "name": "Site", "value": "ann.example" }, + { "type": "PropertyValue", "name": "Pronouns", "value": "she/her" } + ] + } + """); + + var actor = ActorDocument.Parse(json.RootElement); + + Assert.True(actor.Locked); + Assert.True(actor.Indexable); + Assert.Equal("https://m.example/h.png", actor.Header); + Assert.Equal("a cat", actor.IconDescription); + Assert.Equal("mountains", actor.HeaderDescription); + Assert.Equal("https://n.example/users/ann", actor.MovedTo); + Assert.Equal(new DateTime(2023, 4, 1, 0, 0, 0, DateTimeKind.Utc), actor.Published); + Assert.Equal("sparkle", Assert.Single(actor.Emojis).Shortcode); + Assert.Equal(new[] { "Site", "Pronouns" }, actor.Fields.Keys); + Assert.DoesNotContain("