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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-04 10:07:06 +02:00
1 parent d9fb5c582f
commit 31d1d21d1e
13 files changed
+214 -14

No files matched your search

+2 -1
View File
@@ -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 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. 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, 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 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. one is the reader's choice.
+46 -1
View File
@@ -1,4 +1,4 @@
using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post; 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)); 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": "<p>view</p>",
"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": "<p>x</p>", "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] [Fact]
public void Keeps_a_lemmy_link_post_as_a_link_with_its_thumbnail() public void Keeps_a_lemmy_link_post_as_a_link_with_its_thumbnail()
{ {
+106
View File
@@ -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, "<p>the duomo</p>", 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<double>());
Assert.Equal(9.19, place["longitude"]!.GetValue<double>());
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<Post>().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<JsonObject> shape)
{
var note = new JsonObject
{
["id"] = post.ObjectURI,
["type"] = "Note",
["attributedTo"] = author.Id,
["to"] = new JsonArray(MastodonHelpers.Public),
["cc"] = new JsonArray(mentioned.Uri),
["content"] = "<p>the duomo, again</p>",
["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
});
}
}
}
+3 -1
View File
@@ -1,4 +1,4 @@
namespace PrivaPub.Api.Mastodon.Entities namespace PrivaPub.Api.Mastodon.Entities
{ {
public class Account public class Account
{ {
@@ -73,6 +73,7 @@ namespace PrivaPub.Api.Mastodon.Entities
public PrivaPubVideo Video { get; set; } public PrivaPubVideo Video { get; set; }
public PrivaPubAudio Audio { get; set; } public PrivaPubAudio Audio { get; set; }
public PrivaPubEvent Event { 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(); public PrivaPubVotes Votes { get; set; } = new();
} }
@@ -151,6 +152,7 @@ namespace PrivaPub.Api.Mastodon.Entities
{ {
public string Name { get; set; } public string Name { get; set; }
public string Address { 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? Latitude { get; set; }
public double? Longitude { get; set; } public double? Longitude { get; set; }
public string Url { get; set; } public string Url { get; set; }
@@ -1,4 +1,4 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities; using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Api.Mastodon.Infrastructure;
@@ -457,6 +457,10 @@ namespace PrivaPub.Api.Mastodon.Mappers
Status = post.Event.Status, Status = post.Event.Status,
Category = post.Event.Category 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 } Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount }
}; };
+2 -1
View File
@@ -1,4 +1,4 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Statuses; using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
@@ -33,6 +33,7 @@ namespace PrivaPub.Federation.Inbox
post.Video = note.Video ?? post.Video; post.Video = note.Video ?? post.Video;
post.Audio = note.Audio ?? post.Audio; post.Audio = note.Audio ?? post.Audio;
post.Event = note.Event ?? post.Event; post.Event = note.Event ?? post.Event;
post.Place = note.Place;
if (!IsEdit(note, post)) if (!IsEdit(note, post))
{ {
await DB.Default.SaveAsync(post, token); await DB.Default.SaveAsync(post, token);
+2 -1
View File
@@ -1,4 +1,4 @@
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
@@ -90,6 +90,7 @@ namespace PrivaPub.Federation.Inbox
Video = note.Video, Video = note.Video,
Audio = note.Audio, Audio = note.Audio,
Event = note.Event, Event = note.Event,
Place = note.Place,
HasContentWarning = note.Sensitive, HasContentWarning = note.Sensitive,
Text = note.ContentHtml, Text = note.ContentHtml,
ContentHtml = note.ContentHtml, ContentHtml = note.ContentHtml,
+4 -2
View File
@@ -1,4 +1,4 @@
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
@@ -44,6 +44,7 @@ namespace PrivaPub.Federation.Objects
public VideoDetails Video { get; init; } public VideoDetails Video { get; init; }
public AudioDetails Audio { get; init; } public AudioDetails Audio { get; init; }
public EventDetails Event { get; init; } public EventDetails Event { get; init; }
public PostPlace Place { get; init; }
} }
public static class NoteParser public static class NoteParser
@@ -110,7 +111,8 @@ namespace PrivaPub.Federation.Objects
Link = ObjectShapes.Link(note), Link = ObjectShapes.Link(note),
Video = ObjectShapes.Video(note), Video = ObjectShapes.Video(note),
Audio = ObjectShapes.Audio(note), Audio = ObjectShapes.Audio(note),
Event = ObjectShapes.Event(note) Event = ObjectShapes.Event(note),
Place = ObjectShapes.NotePlace(note)
}; };
} }
+26 -1
View File
@@ -1,4 +1,4 @@
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using System.Globalization; using System.Globalization;
using System.Net; 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) static EventPlace Place(JsonObject location)
{ {
var type = Value(location, "type"); var type = Value(location, "type");
+3 -1
View File
@@ -1,4 +1,4 @@
using MongoDB.Bson.Serialization.Attributes; using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Entities; using MongoDB.Entities;
namespace PrivaPub.Models.Post namespace PrivaPub.Models.Post
@@ -39,6 +39,8 @@ namespace PrivaPub.Models.Post
[BsonIgnoreIfNull] [BsonIgnoreIfNull]
public EventDetails Event { get; set; } public EventDetails Event { get; set; }
[BsonIgnoreIfNull] [BsonIgnoreIfNull]
public PostPlace Place { get; set; }
[BsonIgnoreIfNull]
public PostPoll Poll { get; set; } public PostPoll Poll { get; set; }
public List<float> Location { get; set; } = new(); public List<float> Location { get; set; } = new();
public float RangeKm { get; set; } = 5.0f; public float RangeKm { get; set; } = 5.0f;
+10 -1
View File
@@ -1,4 +1,4 @@
namespace PrivaPub.Models.Post namespace PrivaPub.Models.Post
{ {
public class CustomEmoji public class CustomEmoji
{ {
@@ -104,6 +104,15 @@ namespace PrivaPub.Models.Post
public string Category { get; set; } 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 class EventPlace
{ {
public string Name { get; set; } public string Name { get; set; }
+4 -2
View File
@@ -631,8 +631,10 @@ attachment. The player is a card whose iframe loads from the remote host, which
- **Gaps:** - **Gaps:**
- **P1 outbound:** keep JPEG/PNG renditions with an explicit `mediaType`. - **P1 outbound:** keep JPEG/PNG renditions with an explicit `mediaType`.
- **P1 inbound:** Loops' `preview` poster. - **P1 inbound:** Loops' `preview` poster.
- **P2:** Pixelfed `location` → own `privapub.place`, display only and never re-federated; `commentsEnabled: false` - ~~**P2:** Pixelfed `location` → own `privapub.place`, display only and never re-federated~~ done:
disables replies. `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`. - **P3:** ignore `Add{Story}` without an error; answer `FeatureRequest` with `Reject`.
### Long-form: WordPress plugin 9.3.1, Ghost 6, WriteFreely 0.17.2 ### Long-form: WordPress plugin 9.3.1, Ghost 6, WriteFreely 0.17.2
+1 -1
View File
@@ -594,7 +594,7 @@ it, raw where it doesn't.
- Misskey actor extras; - Misskey actor extras;
- book reviews (`relatedWith`, `rating`); - book reviews (`relatedWith`, `rating`);
- Funkwhale and Castopod metadata; - 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. - Bluesky bridging per persona.
### Sweep and statistics (T and M, planned 2026-10-03, between P6 and P7) ### Sweep and statistics (T and M, planned 2026-10-03, between P6 and P7)