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:
1 parent
f41ebf2160
commit
827ecb770e
56 files changed
+5367
-832
No files matched your search
@@ -0,0 +1,177 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using Blazored.LocalStorage;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
using decePubClient.Helpers;
|
||||
using decePubClient.Models;
|
||||
using decePubClient.Models.Mastodon;
|
||||
|
||||
namespace decePubClient.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// What PrivaPub knows about the servers the feed shows (/api/privapub/v1/instances, up to 40 at once), cached in
|
||||
/// memory and localStorage for a day, and where each feed item is drawn. A post is placed, best first: at its own
|
||||
/// place (Pixelfed), at its event's venue, at the reader for a located post, at its author's server (0.1°), or at a
|
||||
/// point in the server's country when PrivaPub publishes only that. A CDN-fronted server has no place.
|
||||
/// </summary>
|
||||
public class InstanceService(IHttpService httpService, ILocalStorageService localStorage, AppConfiguration appConfiguration, ILoggingService logger)
|
||||
{
|
||||
const int MaxHostsPerRead = 40;
|
||||
const string StorageKey = "ServerInfos";
|
||||
static readonly TimeSpan Lifetime = TimeSpan.FromDays(1);
|
||||
static readonly TimeSpan UnknownLifetime = TimeSpan.FromHours(1);
|
||||
|
||||
readonly Dictionary<string, ServerInfo> servers = new(StringComparer.OrdinalIgnoreCase);
|
||||
bool loaded;
|
||||
string selfHost;
|
||||
|
||||
/// <summary>
|
||||
/// PrivaPub's own host, the point every arc starts from: the host of the signed-in persona's account (CascadingState
|
||||
/// sets it), since the API may be reached under another name; the API's host until then.
|
||||
/// </summary>
|
||||
public string SelfHost
|
||||
{
|
||||
get => selfHost ?? appConfiguration.ApiHost;
|
||||
set => selfHost = value;
|
||||
}
|
||||
|
||||
public ServerInfo Known(string host) => host is not null && servers.TryGetValue(host, out var server) && !server.Unknown ? server : null;
|
||||
|
||||
public async Task<ServerInfo> Self(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Resolve([SelfHost], cancellationToken);
|
||||
return Known(SelfHost);
|
||||
}
|
||||
|
||||
/// <summary>Reads the servers not known yet (or known for too long) from PrivaPub.</summary>
|
||||
public async Task Resolve(IEnumerable<string> hosts, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Load(cancellationToken);
|
||||
var now = DateTime.UtcNow;
|
||||
var wanted = hosts.Where(host => host is { Length: > 0 }).Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Where(host => !servers.TryGetValue(host, out var server) || now - server.FetchedAt > (server.Unknown ? UnknownLifetime : Lifetime))
|
||||
.ToList();
|
||||
if (wanted.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var chunk in wanted.Chunk(MaxHostsPerRead))
|
||||
{
|
||||
using var response = await httpService.GetAnon(APIs.PrivaPub.GET_Instances, [.. chunk.Select(host => $"host[]={Uri.EscapeDataString(host)}")],
|
||||
cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
continue;
|
||||
|
||||
var described = await response.MastodonReadFromJsonAsync<List<InstanceDescription>>(cancellationToken) ?? [];
|
||||
foreach (var host in chunk)
|
||||
servers[host] = described.FirstOrDefault(d => string.Equals(d.Host, host, StringComparison.OrdinalIgnoreCase)) is { } description
|
||||
? ToServerInfo(description, now)
|
||||
: new() { Host = host, FetchedAt = now, Unknown = true };
|
||||
}
|
||||
await Save(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Places each post; nearYou is the reader's position, for located posts.</summary>
|
||||
public void Locate(IEnumerable<FeedPost> posts, (double Latitude, double Longitude)? nearYou = default)
|
||||
{
|
||||
foreach (var post in posts)
|
||||
post.Location = Locate(post, nearYou);
|
||||
}
|
||||
|
||||
FeedLocation Locate(FeedPost post, (double Latitude, double Longitude)? nearYou)
|
||||
{
|
||||
if (post.Status?.Privapub?.Place is { Latitude: { } placeLatitude, Longitude: { } placeLongitude } place)
|
||||
return new(placeLatitude, placeLongitude, LocationSource.Place, JoinLabel(place.Name, place.Country), 1);
|
||||
|
||||
if (post.Status?.Privapub?.Event?.Places.FirstOrDefault(p => p.Latitude is not null && p.Longitude is not null) is { } venue)
|
||||
return new(venue.Latitude!.Value, venue.Longitude!.Value, LocationSource.Event, venue.Name ?? venue.Address, 1);
|
||||
|
||||
if (post.Located && nearYou is { } reader)
|
||||
return new(reader.Latitude, reader.Longitude, LocationSource.NearYou, null, post.RangeKm ?? 5);
|
||||
|
||||
var geo = Known(post.Host)?.Geo;
|
||||
if (geo is { Latitude: { } latitude, Longitude: { } longitude })
|
||||
return new(latitude, longitude, LocationSource.Server, JoinLabel(geo.City, CountryName(geo.Country)), 11);
|
||||
|
||||
if (CountryPoints.Of(geo?.Country) is { } country)
|
||||
return new(country.Latitude, country.Longitude, LocationSource.Country, CountryName(geo.Country), 500);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The country's English name, or the code when .NET does not know it. Not DisplayName: in the browser it falls back
|
||||
/// to the country's own language ("日本").
|
||||
/// </summary>
|
||||
public static string CountryName(string countryCode)
|
||||
{
|
||||
if (countryCode is not { Length: 2 })
|
||||
return countryCode;
|
||||
try
|
||||
{
|
||||
return new System.Globalization.RegionInfo(countryCode).EnglishName;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return countryCode;
|
||||
}
|
||||
}
|
||||
|
||||
static string JoinLabel(params string[] parts) => string.Join(", ", parts.Where(part => !string.IsNullOrWhiteSpace(part)));
|
||||
|
||||
static ServerInfo ToServerInfo(InstanceDescription description, DateTime fetchedAt) => new()
|
||||
{
|
||||
Host = description.Host,
|
||||
Software = description.Software,
|
||||
Version = description.Version,
|
||||
NodeName = description.NodeName,
|
||||
OpenRegistrations = description.OpenRegistrations,
|
||||
Users = Number(description.NodeInfo, "usage", "users", "total"),
|
||||
ActiveMonth = Number(description.NodeInfo, "usage", "users", "activeMonth"),
|
||||
LocalPosts = Number(description.NodeInfo, "usage", "localPosts"),
|
||||
Geo = description.Geo,
|
||||
ConsecutiveFailures = description.Delivery?.ConsecutiveFailures ?? 0,
|
||||
LastSuccessAt = description.Delivery?.LastSuccessAt,
|
||||
LastFailureAt = description.Delivery?.LastFailureAt,
|
||||
DescribedAt = description.DescribedAt,
|
||||
DescriptionError = description.DescriptionError,
|
||||
FetchedAt = fetchedAt
|
||||
};
|
||||
|
||||
static long? Number(JsonNode node, params string[] path)
|
||||
{
|
||||
foreach (var step in path)
|
||||
node = node is JsonObject obj ? obj[step] : null;
|
||||
return node is JsonValue value && value.TryGetValue<long>(out var number) ? number : null;
|
||||
}
|
||||
|
||||
async Task Load(CancellationToken cancellationToken)
|
||||
{
|
||||
if (loaded)
|
||||
return;
|
||||
loaded = true;
|
||||
try
|
||||
{
|
||||
foreach (var server in await localStorage.GetItemAsync<List<ServerInfo>>(StorageKey, cancellationToken) ?? [])
|
||||
servers.TryAdd(server.Host, server);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await logger.ProcessWarning(ex.Message, $"{nameof(InstanceService)}.{nameof(Load)}", cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
async Task Save(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fresh = servers.Values.Where(server => DateTime.UtcNow - server.FetchedAt <= Lifetime).ToList();
|
||||
await localStorage.SetItemAsync(StorageKey, fresh, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await logger.ProcessWarning(ex.Message, $"{nameof(InstanceService)}.{nameof(Save)}", cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user