From 31d1d21d1e585818c83020fe1dea4646673230a0 Mon Sep 17 00:00:00 2001 From: thepra Date: Sun, 4 Oct 2026 10:07:06 +0200 Subject: [PATCH] Pixelfed places: a remote post's own place, shown and never federated again A Note's `location` that is a Place with coordinates (Pixelfed sends them as strings) is kept in the new Post.Place and shown as Status.privapub.place {name, latitude, longitude, country}; an edit replaces it. An Event's location stays its own `places`, and nothing renders a place back out. Closes the INTEROP P2 Pixelfed location gap and the ROADMAP long-tail item. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 3 +- .../Federation/ObjectShapesTests.cs | 47 +++++++- PrivaPub.Tests/Http/PostPlaceTests.cs | 106 ++++++++++++++++++ PrivaPub/Api/Mastodon/Entities/Entities.cs | 4 +- .../Api/Mastodon/Mappers/MastodonMapper.cs | 6 +- PrivaPub/Federation/Inbox/RemoteEdits.cs | 3 +- PrivaPub/Federation/Inbox/RemotePosts.cs | 3 +- PrivaPub/Federation/Objects/NoteParser.cs | 6 +- PrivaPub/Federation/Objects/ObjectShapes.cs | 27 ++++- PrivaPub/Models/Post/Post.cs | 4 +- PrivaPub/Models/Post/PostDetails.cs | 11 +- docs/INTEROP.md | 6 +- docs/ROADMAP.md | 2 +- 13 files changed, 214 insertions(+), 14 deletions(-) create mode 100644 PrivaPub.Tests/Http/PostPlaceTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 45e721a..102e8e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -240,7 +240,8 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. because clients show quotes from it, so everything a 4.3+ client calls because of it must answer: grouped notifications (`/api/v2/notifications`, its unread count, groups, accounts and dismiss) and the notification policy. 7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt, - cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes + cover, the author's source, link, video, audio and event details, a remote post's own place (Pixelfed's `location`, + never federated again), and up/down votes. Every media URL in it goes through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following one is the reader's choice. diff --git a/PrivaPub.Tests/Federation/ObjectShapesTests.cs b/PrivaPub.Tests/Federation/ObjectShapesTests.cs index 70d1a1e..9ee8e4f 100644 --- a/PrivaPub.Tests/Federation/ObjectShapesTests.cs +++ b/PrivaPub.Tests/Federation/ObjectShapesTests.cs @@ -1,4 +1,4 @@ -using PrivaPub.Api.Mastodon.Mappers; +using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Federation.Objects; using PrivaPub.Models.Post; @@ -79,6 +79,51 @@ namespace PrivaPub.Tests.Federation Assert.Equal("2026-10-03 10:00 – 13:00 (Europe/Rome) · Biblioteca", MastodonMapper.EventLine(note.Event)); } + [Fact] + public void Keeps_a_pixelfed_place_whose_coordinates_are_strings() + { + var note = Parse(""" + { + "id": "https://pixel.example/p/ann/1", "type": "Note", "attributedTo": "https://pixel.example/users/ann", "content": "

view

", + "location": { "type": "Place", "name": "Tokyo", "longitude": "139.6917", "latitude": "35.6895", "country": "Japan" }, + "commentsEnabled": true + } + """); + + Assert.Equal(("Tokyo", 35.6895, 139.6917, "Japan"), (note.Place.Name, note.Place.Latitude, note.Place.Longitude, note.Place.Country)); + Assert.Null(note.Event); + } + + [Theory] + [InlineData("null")] + [InlineData("""{ "type": "Place", "name": "Nowhere" }""")] + [InlineData("""{ "type": "Place", "name": "Off the map", "latitude": 123, "longitude": 9 }""")] + [InlineData("""{ "type": "Place", "latitude": "north", "longitude": "9.19" }""")] + [InlineData("""{ "type": "VirtualLocation", "url": "https://meet.example/a", "latitude": 45, "longitude": 9 }""")] + public void Has_no_place_without_a_place_with_coordinates(string location) + { + var note = Parse($$""" + { "id": "https://pixel.example/p/ann/2", "type": "Note", "attributedTo": "https://pixel.example/users/ann", "content": "

x

", "location": {{location}} } + """); + + Assert.Null(note.Place); + } + + [Fact] + public void An_events_location_stays_the_events_own() + { + var note = Parse(""" + { + "id": "https://events.example/events/10", "type": "Event", "name": "Picnic", "startTime": "2026-10-03T10:00:00+02:00", + "location": { "type": "Place", "name": "Park", "latitude": 45.07, "longitude": 7.68 }, + "attributedTo": "https://events.example/@club" + } + """); + + Assert.Null(note.Place); + Assert.Equal("Park", Assert.Single(note.Event.Places).Name); + } + [Fact] public void Keeps_a_lemmy_link_post_as_a_link_with_its_thumbnail() { diff --git a/PrivaPub.Tests/Http/PostPlaceTests.cs b/PrivaPub.Tests/Http/PostPlaceTests.cs new file mode 100644 index 0000000..0089cb5 --- /dev/null +++ b/PrivaPub.Tests/Http/PostPlaceTests.cs @@ -0,0 +1,106 @@ +using MongoDB.Entities; + +using PrivaPub.Models.Post; +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Http +{ + // A Pixelfed post's own place: stored from `location`, shown as `privapub.place`, changed by an edit. + [Trait("Category", "Integration")] + public sealed class PostPlaceTests : IAsyncLifetime + { + PrivaPubHost _host; + Peer _peer; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + _peer = await Peer.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_peer != default) + await _peer.DisposeAsync(); + } + + static CancellationToken Token => TestContext.Current.CancellationToken; + + static JsonObject Location(string name, string latitude, string longitude) => new() + { + ["type"] = "Place", ["name"] = name, ["latitude"] = latitude, ["longitude"] = longitude, ["country"] = "Italy" + }; + + [Fact] + public async Task A_pixelfed_place_is_shown_and_follows_the_authors_edits() + { + var alice = await _host.Mastodon("alice"); + var ann = new RemoteActor(_peer, "ann"); + var post = await _host.PublicPostFrom(ann, alice, "

the duomo

", note => note["location"] = Location("Milan", "45.4642", "9.19")); + + var place = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body["privapub"]!["place"]; + + Assert.Equal(("Milan", "Italy"), (place.Text("name"), place.Text("country"))); + Assert.Equal(45.4642, place["latitude"]!.GetValue()); + Assert.Equal(9.19, place["longitude"]!.GetValue()); + + await Edit(ann, alice, post, note => note["location"] = Location("Turin", "45.0703", "7.6869")); + Assert.Equal("Turin", (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body["privapub"]!["place"].Text("name")); + + await Edit(ann, alice, post, _ => { }); + Assert.Null((await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body["privapub"]!["place"]); + Assert.Null((await DB.Default.Find().MatchID(post.ID).ExecuteFirstAsync(Token)).Place); + } + + [Fact] + public async Task A_post_without_a_place_has_none() + { + var alice = await _host.Mastodon("alice"); + var post = await _host.PublicPostFrom(new RemoteActor(_peer, "plain"), alice); + + Assert.Null((await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body["privapub"]!["place"]); + } + + [Fact] + public async Task A_local_post_never_carries_a_location() + { + var alice = await _host.Mastodon("alice"); + var status = await alice.Status("made here"); + + using var client = _host.Client(); + client.DefaultRequestHeaders.Accept.ParseAdd("application/activity+json"); + var note = JsonNode.Parse(await client.GetStringAsync(status.Text("uri"), Token))!; + + Assert.Null(note["location"]); + } + + async Task Edit(RemoteActor author, Mastodon mentioned, Post post, Action shape) + { + var note = new JsonObject + { + ["id"] = post.ObjectURI, + ["type"] = "Note", + ["attributedTo"] = author.Id, + ["to"] = new JsonArray(MastodonHelpers.Public), + ["cc"] = new JsonArray(mentioned.Uri), + ["content"] = "

the duomo, again

", + ["published"] = post.CreationDate.ToString("O"), + ["updated"] = DateTime.UtcNow.ToString("O"), + ["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = mentioned.Uri, ["name"] = "@" + mentioned.UserName }) + }; + shape(note); + await _host.Deliver(author, mentioned.Mouth, new JsonObject + { + ["id"] = $"{post.ObjectURI}/updates/{Guid.NewGuid():N}", + ["type"] = "Update", + ["actor"] = author.Id, + ["to"] = new JsonArray(MastodonHelpers.Public), + ["object"] = note + }); + } + } +} diff --git a/PrivaPub/Api/Mastodon/Entities/Entities.cs b/PrivaPub/Api/Mastodon/Entities/Entities.cs index d1c8437..a24ec6e 100644 --- a/PrivaPub/Api/Mastodon/Entities/Entities.cs +++ b/PrivaPub/Api/Mastodon/Entities/Entities.cs @@ -1,4 +1,4 @@ -namespace PrivaPub.Api.Mastodon.Entities +namespace PrivaPub.Api.Mastodon.Entities { public class Account { @@ -73,6 +73,7 @@ namespace PrivaPub.Api.Mastodon.Entities public PrivaPubVideo Video { get; set; } public PrivaPubAudio Audio { get; set; } public PrivaPubEvent Event { get; set; } + public PrivaPubPlace Place { get; set; }//a remote post's own place (Pixelfed), shown only public PrivaPubVotes Votes { get; set; } = new(); } @@ -151,6 +152,7 @@ namespace PrivaPub.Api.Mastodon.Entities { public string Name { get; set; } public string Address { get; set; } + public string Country { get; set; }//a post's own place only public double? Latitude { get; set; } public double? Longitude { get; set; } public string Url { get; set; } diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index 8cef9ef..a82b810 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -1,4 +1,4 @@ -using MongoDB.Entities; +using MongoDB.Entities; using PrivaPub.Api.Mastodon.Entities; using PrivaPub.Api.Mastodon.Infrastructure; @@ -457,6 +457,10 @@ namespace PrivaPub.Api.Mastodon.Mappers Status = post.Event.Status, Category = post.Event.Category }, + Place = post.Place == default ? default : new PrivaPubPlace + { + Name = post.Place.Name, Latitude = post.Place.Latitude, Longitude = post.Place.Longitude, Country = post.Place.Country + }, Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount } }; diff --git a/PrivaPub/Federation/Inbox/RemoteEdits.cs b/PrivaPub/Federation/Inbox/RemoteEdits.cs index 25585a0..7a221c9 100644 --- a/PrivaPub/Federation/Inbox/RemoteEdits.cs +++ b/PrivaPub/Federation/Inbox/RemoteEdits.cs @@ -1,4 +1,4 @@ -using MongoDB.Entities; +using MongoDB.Entities; using PrivaPub.Domain.Statuses; using PrivaPub.Federation.Actors; @@ -33,6 +33,7 @@ namespace PrivaPub.Federation.Inbox post.Video = note.Video ?? post.Video; post.Audio = note.Audio ?? post.Audio; post.Event = note.Event ?? post.Event; + post.Place = note.Place; if (!IsEdit(note, post)) { await DB.Default.SaveAsync(post, token); diff --git a/PrivaPub/Federation/Inbox/RemotePosts.cs b/PrivaPub/Federation/Inbox/RemotePosts.cs index 192075e..e8f580b 100644 --- a/PrivaPub/Federation/Inbox/RemotePosts.cs +++ b/PrivaPub/Federation/Inbox/RemotePosts.cs @@ -1,4 +1,4 @@ -using MongoDB.Driver; +using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Infrastructure.Http; @@ -90,6 +90,7 @@ namespace PrivaPub.Federation.Inbox Video = note.Video, Audio = note.Audio, Event = note.Event, + Place = note.Place, HasContentWarning = note.Sensitive, Text = note.ContentHtml, ContentHtml = note.ContentHtml, diff --git a/PrivaPub/Federation/Objects/NoteParser.cs b/PrivaPub/Federation/Objects/NoteParser.cs index 04f6b11..a5ff96b 100644 --- a/PrivaPub/Federation/Objects/NoteParser.cs +++ b/PrivaPub/Federation/Objects/NoteParser.cs @@ -1,4 +1,4 @@ -using PrivaPub.Models.Post; +using PrivaPub.Models.Post; using System.Globalization; using System.Text; @@ -44,6 +44,7 @@ namespace PrivaPub.Federation.Objects public VideoDetails Video { get; init; } public AudioDetails Audio { get; init; } public EventDetails Event { get; init; } + public PostPlace Place { get; init; } } public static class NoteParser @@ -110,7 +111,8 @@ namespace PrivaPub.Federation.Objects Link = ObjectShapes.Link(note), Video = ObjectShapes.Video(note), Audio = ObjectShapes.Audio(note), - Event = ObjectShapes.Event(note) + Event = ObjectShapes.Event(note), + Place = ObjectShapes.NotePlace(note) }; } diff --git a/PrivaPub/Federation/Objects/ObjectShapes.cs b/PrivaPub/Federation/Objects/ObjectShapes.cs index 7eb68bd..cded5ba 100644 --- a/PrivaPub/Federation/Objects/ObjectShapes.cs +++ b/PrivaPub/Federation/Objects/ObjectShapes.cs @@ -1,4 +1,4 @@ -using PrivaPub.Models.Post; +using PrivaPub.Models.Post; using System.Globalization; using System.Net; @@ -293,6 +293,31 @@ namespace PrivaPub.Federation.Objects }; } + //a post's own place, as Pixelfed sends it: a Place with coordinates (numbers, or numbers as strings); an Event's + //places are its own (Event) + public static PostPlace NotePlace(JsonObject note) + { + if (Value(note, "type") == "Event") + return default; + var place = Objects(note["location"]).FirstOrDefault(l => Value(l, "type") == "Place"); + var latitude = Coordinate(place, "latitude"); + var longitude = Coordinate(place, "longitude"); + if (latitude is not (>= -90 and <= 90) || longitude is not (>= -180 and <= 180)) + return default; + return new PostPlace + { + Name = Text(Value(place, "name"), 200), + Latitude = Math.Round(latitude.Value, 4), + Longitude = Math.Round(longitude.Value, 4), + Country = Text(Value(place, "country"), 100) + }; + } + + static double? Coordinate(JsonObject node, string property) => + Double(node, property) ?? (double.TryParse(Value(node, property), NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && double.IsFinite(number) + ? number + : (double?)null); + static EventPlace Place(JsonObject location) { var type = Value(location, "type"); diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs index e897d86..937e6a6 100644 --- a/PrivaPub/Models/Post/Post.cs +++ b/PrivaPub/Models/Post/Post.cs @@ -1,4 +1,4 @@ -using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Bson.Serialization.Attributes; using MongoDB.Entities; namespace PrivaPub.Models.Post @@ -39,6 +39,8 @@ namespace PrivaPub.Models.Post [BsonIgnoreIfNull] public EventDetails Event { get; set; } [BsonIgnoreIfNull] + public PostPlace Place { get; set; } + [BsonIgnoreIfNull] public PostPoll Poll { get; set; } public List Location { get; set; } = new(); public float RangeKm { get; set; } = 5.0f; diff --git a/PrivaPub/Models/Post/PostDetails.cs b/PrivaPub/Models/Post/PostDetails.cs index e0b2cab..be2452f 100644 --- a/PrivaPub/Models/Post/PostDetails.cs +++ b/PrivaPub/Models/Post/PostDetails.cs @@ -1,4 +1,4 @@ -namespace PrivaPub.Models.Post +namespace PrivaPub.Models.Post { public class CustomEmoji { @@ -104,6 +104,15 @@ namespace PrivaPub.Models.Post public string Category { get; set; } } + //a remote post's own place (Pixelfed's location): shown, never federated again + public class PostPlace + { + public string Name { get; set; } + public double Latitude { get; set; } + public double Longitude { get; set; } + public string Country { get; set; } + } + public class EventPlace { public string Name { get; set; } diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 217f211..8a6a20c 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -631,8 +631,10 @@ attachment. The player is a card whose iframe loads from the remote host, which - **Gaps:** - **P1 outbound:** keep JPEG/PNG renditions with an explicit `mediaType`. - **P1 inbound:** Loops' `preview` poster. - - **P2:** Pixelfed `location` → own `privapub.place`, display only and never re-federated; `commentsEnabled: false` - disables replies. + - ~~**P2:** Pixelfed `location` → own `privapub.place`, display only and never re-federated~~ done: + `ObjectShapes.NotePlace` keeps a `Place` with coordinates (Pixelfed sends them as strings) in `Post.Place`, an edit + replaces it, and nothing renders it back out. + - **P2:** `commentsEnabled: false` disables replies. - **P3:** ignore `Add{Story}` without an error; answer `FeatureRequest` with `Reject`. ### Long-form: WordPress plugin 9.3.1, Ghost 6, WriteFreely 0.17.2 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c315163..593535e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -594,7 +594,7 @@ it, raw where it doesn't. - Misskey actor extras; - book reviews (`relatedWith`, `rating`); - Funkwhale and Castopod metadata; - - Pixelfed `place` (display only, never re-federated); + - ~~Pixelfed `place` (display only, never re-federated)~~ done 2026-10-04: `Post.Place`, `privapub.place`; - Bluesky bridging per persona. ### Sweep and statistics (T and M, planned 2026-10-03, between P6 and P7)