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:
1 parent
d9fb5c582f
commit
31d1d21d1e
13 files changed
+214
-14
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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": "<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]
|
||||
public void Keeps_a_lemmy_link_post_as_a_link_with_its_thumbnail()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<float> Location { get; set; } = new();
|
||||
public float RangeKm { get; set; } = 5.0f;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user