Wire the client to PrivaPub: one sign-in, real feeds, a globe, nerd stats

One sign-in for both of PrivaPub's APIs: the root logs in on /clientapi,
picks a persona, and each persona gets its own Mastodon token in
exchange for the JWT (PersonaExchange). HttpService picks credentials
and JSON by route (JWT and camelCase for /clientapi, the persona token
and snake_case for /api, nothing for /oauth), retries a refused persona
token once, and records every request for the nerd stats.

The feed page is three columns: navigation with a persona switcher, the
feed with a tab per kind (home, local, federated, nearby, communities
and circles, direct, notifications, favourites, bookmarks, hashtags),
and a 3D globe that looks at the spherical mean of the posts in view
and draws a pulse and an arc to a hovered one. Posts sit at their own
place, their event's venue, the reader (located posts) or their
author's server as PrivaPub publishes it. Cards favourite, boost,
bookmark, reply, mute, block and delete; the composer posts with a
content warning, visibility, media and alt text, or as a located post.
Older pages load as the end nears; newer ones are polled while visible.
Nerd stats in tooltips: provenance, author and server, place, media,
counts, paging, the globe's camera and frame rate.

The mock (Faker, MessagesService, the Message models and store) and the
OIDC template leftovers are gone. Strings in English and Italian.

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 11:03:14 +02:00
1 parent f41ebf2160
commit 827ecb770e
56 files changed
+5367 -832

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
using System.Collections.Frozen;
namespace decePubClient.Helpers;
/// <summary>
/// A point inside each country (ISO 3166-1 alpha-2), for servers PrivaPub places only by country: the spherical mean of
/// the country's zones in the tz database's zone.tab (public domain, tzdata 2026c). Regenerate it from a newer zone.tab
/// the same way when a country code is missing.
/// </summary>
public static class CountryPoints
{
public static readonly FrozenDictionary<string, (double Latitude, double Longitude)> All = new Dictionary<string, (double, double)>
{
["AD"] = (42.5, 1.5), ["AE"] = (25.3, 55.3), ["AF"] = (34.5, 69.2), ["AG"] = (17.1, -61.8), ["AI"] = (18.2, -63.1), ["AL"] = (41.3, 19.8),
["AM"] = (40.2, 44.5), ["AO"] = (-8.8, 13.2), ["AQ"] = (-82.9, 67.0), ["AR"] = (-33.6, -65.9), ["AS"] = (-14.3, -170.7), ["AT"] = (48.2, 16.3),
["AU"] = (-33.2, 142.8), ["AW"] = (12.5, -70.0), ["AX"] = (60.1, 19.9), ["AZ"] = (40.4, 49.9), ["BA"] = (43.9, 18.4), ["BB"] = (13.1, -59.6),
["BD"] = (23.7, 90.4), ["BE"] = (50.8, 4.3), ["BF"] = (12.4, -1.5), ["BG"] = (42.7, 23.3), ["BH"] = (26.4, 50.6), ["BI"] = (-3.4, 29.4),
["BJ"] = (6.5, 2.6), ["BL"] = (17.9, -62.8), ["BM"] = (32.3, -64.8), ["BN"] = (4.9, 114.9), ["BO"] = (-16.5, -68.2), ["BQ"] = (12.2, -68.3),
["BR"] = (-8.6, -50.7), ["BS"] = (25.1, -77.3), ["BT"] = (27.5, 89.7), ["BW"] = (-24.7, 25.9), ["BY"] = (53.9, 27.6), ["BZ"] = (17.5, -88.2),
["CA"] = (57.9, -93.3), ["CC"] = (-12.2, 96.9), ["CD"] = (-8.0, 21.3), ["CF"] = (4.4, 18.6), ["CG"] = (-4.3, 15.3), ["CH"] = (47.4, 8.5),
["CI"] = (5.3, -4.0), ["CK"] = (-21.2, -159.8), ["CL"] = (-41.1, -82.2), ["CM"] = (4.0, 9.7), ["CN"] = (38.7, 106.0), ["CO"] = (4.6, -74.1),
["CR"] = (9.9, -84.1), ["CU"] = (23.1, -82.4), ["CV"] = (14.9, -23.5), ["CW"] = (12.2, -69.0), ["CX"] = (-10.4, 105.7), ["CY"] = (35.1, 33.7),
["CZ"] = (50.1, 14.4), ["DE"] = (50.1, 10.9), ["DJ"] = (11.6, 43.1), ["DK"] = (55.7, 12.6), ["DM"] = (15.3, -61.4), ["DO"] = (18.5, -69.9),
["DZ"] = (36.8, 3.0), ["EC"] = (-1.5, -84.7), ["EE"] = (59.4, 24.8), ["EG"] = (30.1, 31.3), ["EH"] = (27.2, -13.2), ["ER"] = (15.3, 38.9),
["ES"] = (34.9, -8.4), ["ET"] = (9.0, 38.7), ["FI"] = (60.2, 25.0), ["FJ"] = (-18.1, 178.4), ["FK"] = (-51.7, -57.9), ["FM"] = (6.6, 157.7),
["FO"] = (62.0, -6.8), ["FR"] = (48.9, 2.3), ["GA"] = (0.4, 9.4), ["GB"] = (51.5, -0.1), ["GD"] = (12.1, -61.8), ["GE"] = (41.7, 44.8),
["GF"] = (4.9, -52.3), ["GG"] = (49.5, -2.5), ["GH"] = (5.6, -0.2), ["GI"] = (36.1, -5.3), ["GL"] = (73.0, -40.7), ["GM"] = (13.5, -16.6),
["GN"] = (9.5, -13.7), ["GP"] = (16.2, -61.5), ["GQ"] = (3.7, 8.8), ["GR"] = (38.0, 23.7), ["GS"] = (-54.3, -36.5), ["GT"] = (14.6, -90.5),
["GU"] = (13.5, 144.8), ["GW"] = (11.8, -15.6), ["GY"] = (6.8, -58.2), ["HK"] = (22.3, 114.2), ["HN"] = (14.1, -87.2), ["HR"] = (45.8, 16.0),
["HT"] = (18.5, -72.3), ["HU"] = (47.5, 19.1), ["ID"] = (-3.6, 119.0), ["IE"] = (53.3, -6.2), ["IL"] = (31.8, 35.2), ["IM"] = (54.1, -4.5),
["IN"] = (22.5, 88.4), ["IO"] = (-7.3, 72.4), ["IQ"] = (33.4, 44.4), ["IR"] = (35.7, 51.4), ["IS"] = (64.2, -21.9), ["IT"] = (41.9, 12.5),
["JE"] = (49.2, -2.1), ["JM"] = (18.0, -76.8), ["JO"] = (32.0, 35.9), ["JP"] = (35.7, 139.7), ["KE"] = (-1.3, 36.8), ["KG"] = (42.9, 74.6),
["KH"] = (11.6, 104.9), ["KI"] = (0.2, -172.0), ["KM"] = (-11.7, 43.3), ["KN"] = (17.3, -62.7), ["KP"] = (39.0, 125.7), ["KR"] = (37.5, 127.0),
["KW"] = (29.3, 48.0), ["KY"] = (19.3, -81.4), ["KZ"] = (48.1, 59.7), ["LA"] = (18.0, 102.6), ["LB"] = (33.9, 35.5), ["LC"] = (14.0, -61.0),
["LI"] = (47.1, 9.5), ["LK"] = (6.9, 79.8), ["LR"] = (6.3, -10.8), ["LS"] = (-29.5, 27.5), ["LT"] = (54.7, 25.3), ["LU"] = (49.6, 6.2),
["LV"] = (57.0, 24.1), ["LY"] = (32.9, 13.2), ["MA"] = (33.6, -7.6), ["MC"] = (43.7, 7.4), ["MD"] = (47.0, 28.8), ["ME"] = (42.4, 19.3),
["MF"] = (18.1, -63.1), ["MG"] = (-18.9, 47.5), ["MH"] = (8.1, 169.3), ["MK"] = (42.0, 21.4), ["ML"] = (12.7, -8.0), ["MM"] = (16.8, 96.2),
["MN"] = (48.2, 99.3), ["MO"] = (22.2, 113.5), ["MP"] = (15.2, 145.8), ["MQ"] = (14.6, -61.1), ["MR"] = (18.1, -16.0), ["MS"] = (16.7, -62.2),
["MT"] = (35.9, 14.5), ["MU"] = (-20.2, 57.5), ["MV"] = (4.2, 73.5), ["MW"] = (-15.8, 35.0), ["MX"] = (25.9, -102.3), ["MY"] = (2.4, 106.0),
["MZ"] = (-26.0, 32.6), ["NA"] = (-22.6, 17.1), ["NC"] = (-22.3, 166.4), ["NE"] = (13.5, 2.1), ["NF"] = (-29.1, 168.0), ["NG"] = (6.4, 3.4),
["NI"] = (12.2, -86.3), ["NL"] = (52.4, 4.9), ["NO"] = (59.9, 10.8), ["NP"] = (27.7, 85.3), ["NR"] = (-0.5, 166.9), ["NU"] = (-19.0, -169.9),
["NZ"] = (-40.5, 178.9), ["OM"] = (23.6, 58.6), ["PA"] = (9.0, -79.5), ["PE"] = (-12.1, -77.0), ["PF"] = (-16.6, -141.4), ["PG"] = (-7.9, 151.4),
["PH"] = (14.6, 121.0), ["PK"] = (24.9, 67.0), ["PL"] = (52.2, 21.0), ["PM"] = (47.0, -56.3), ["PN"] = (-25.1, -130.1), ["PR"] = (18.5, -66.1),
["PS"] = (31.5, 34.8), ["PT"] = (36.5, -17.3), ["PW"] = (7.3, 134.5), ["PY"] = (-25.3, -57.7), ["QA"] = (25.3, 51.5), ["RE"] = (-20.9, 55.5),
["RO"] = (44.4, 26.1), ["RS"] = (44.8, 20.5), ["RU"] = (63.0, 90.5), ["RW"] = (-1.9, 30.1), ["SA"] = (24.6, 46.7), ["SB"] = (-9.5, 160.2),
["SC"] = (-4.7, 55.5), ["SD"] = (15.6, 32.5), ["SE"] = (59.3, 18.1), ["SG"] = (1.3, 103.8), ["SH"] = (-15.9, -5.7), ["SI"] = (46.0, 14.5),
["SJ"] = (78.0, 16.0), ["SK"] = (48.1, 17.1), ["SL"] = (8.5, -13.2), ["SM"] = (43.9, 12.5), ["SN"] = (14.7, -17.4), ["SO"] = (2.1, 45.4),
["SR"] = (5.8, -55.2), ["SS"] = (4.8, 31.6), ["ST"] = (0.3, 6.7), ["SV"] = (13.7, -89.2), ["SX"] = (18.1, -63.0), ["SY"] = (33.5, 36.3),
["SZ"] = (-26.3, 31.1), ["TC"] = (21.5, -71.1), ["TD"] = (12.1, 15.1), ["TF"] = (-49.4, 70.2), ["TG"] = (6.1, 1.2), ["TH"] = (13.7, 100.5),
["TJ"] = (38.6, 68.8), ["TK"] = (-9.4, -171.2), ["TL"] = (-8.6, 125.6), ["TM"] = (38.0, 58.4), ["TN"] = (36.8, 10.2), ["TO"] = (-21.1, -175.2),
["TR"] = (41.0, 29.0), ["TT"] = (10.7, -61.5), ["TV"] = (-8.5, 179.2), ["TW"] = (25.1, 121.5), ["TZ"] = (-6.8, 39.3), ["UA"] = (47.7, 32.4),
["UG"] = (0.3, 32.4), ["UM"] = (24.0, 174.3), ["US"] = (47.3, -104.5), ["UY"] = (-34.9, -56.2), ["UZ"] = (40.5, 68.0), ["VA"] = (41.9, 12.5),
["VC"] = (13.2, -61.2), ["VE"] = (10.5, -66.9), ["VG"] = (18.5, -64.6), ["VI"] = (18.4, -64.9), ["VN"] = (10.8, 106.7), ["VU"] = (-17.7, 168.4),
["WF"] = (-13.3, -176.2), ["WS"] = (-13.8, -171.7), ["YE"] = (12.8, 45.2), ["YT"] = (-12.8, 45.2), ["ZA"] = (-26.2, 28.0), ["ZM"] = (-15.4, 28.3),
["ZW"] = (-17.8, 31.1)
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
public static (double Latitude, double Longitude)? Of(string countryCode) =>
countryCode is { Length: 2 } && All.TryGetValue(countryCode, out var point) ? point : null;
}
+15 -2
View File
@@ -1,4 +1,4 @@
using System.Net;
using System.Net;
using System.Net.Http.Json;
using System.Net.Mail;
using System.Text.Json;
@@ -21,13 +21,26 @@ namespace decePubClient.Helpers
/// <summary>Marks a response HttpService made up itself (a classified failure), not one the server sent.</summary>
public const string FailureHeader = "X-DecePub-Failure";
/// <summary>The JSON options for every HTTP payload: web defaults, ClientJsonContext first, reflection after.</summary>
/// <summary>
/// The JSON options for /clientapi payloads and localStorage: web defaults, enums by name as PrivaPub writes them
/// (numbers still read), ClientJsonContext first, reflection after.
/// </summary>
public static JsonSerializerOptions DefaultSerializer { get; } = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
TypeInfoResolver = JsonTypeInfoResolver.Combine(ClientJsonContext.Default, new DefaultJsonTypeInfoResolver())
};
/// <summary>The JSON options for PrivaPub's Mastodon API and /oauth: snake_case, MastodonJsonContext first.</summary>
public static JsonSerializerOptions MastodonSerializer { get; } = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
IgnoreReadOnlyProperties = true,
TypeInfoResolver = JsonTypeInfoResolver.Combine(MastodonJsonContext.Default, new DefaultJsonTypeInfoResolver())
};
/// <summary>What a cancelled request turns into: HTTP 410, which AddResponseError ignores.</summary>
public static WebResult DefaultTaskCancelledResult =>
new WebResult().Invalidate("Request cancelled, not an error.", (int)HttpStatusCode.Gone, FailureCodes.Cancelled);