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("