Files
decePubClient/Services/InstanceService.cs
T
thepraandClaude Opus 5.5 fd2f2e7c65
Build / Build (push) Successful in 51s
R1 cleanup: collAnon and dead code gone, no IndexedDB, honest PWA files
Starts the completion program (SocialPub docs/ROADMAP.md, "Owner decisions on finishing everything", 2026-10-17).

- The localizer's namespace is decePubClient.Services; collAnon is gone from the repo.
- Dead code: PagesBase, VConstants, SettingsOptions, ViewLanguage, unused APIs routes, InstanceService.Self,
  RequestStats.LastOf/AverageMs, PostAnon/DeleteTotallyAnon, unused AppStatusService methods and their main.js helpers
  (the app badge stays for Web Push), ExtensionMethods/SUtility leftovers (Markdig and BlazorZXingJs packages go),
  unused PublicCacheData/PageSettings/AppConfiguration fields, commented-out code.
- The client log in IndexedDB, which nothing ever read, goes with the whole IndexedDB layer (DnetIndexedDb, IStorage):
  logs go to the browser console; main.js deletes the old "data" database once.
- The solution names the real ClientModels project. Unused vendor assets (fontawesome, solid, open-iconic, rxjs) go.
- robots.txt disallows everything; manifest.json and index.html say decePub; the service worker's cache name follows
  each publish's assets version, so old caches are purged.
- MessageType is Visibility (member names kept: they are resx keys); ExpandMessage.razor is Thread.razor (same route).
- i18n: dead keys removed, Italian "camera" is "punto di vista", DInputBase/DCalendar validation messages come from
  ErrorMessages, DataTransfer's spinner has its label.
- CLAUDE.md follows: client storage, JS interop, PWA, the D fields' messages.

Verified: dotnet build decePubClient.csproj and decePubClient.sln -c Release (0 errors, no new warnings); a Pasture
publish served by the pasture's decepub peer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 02:56:57 +02:00

213 lines
8.7 KiB
C#

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 located it only to a country. A CDN-fronted server has no place: its
/// posts join their CDN's group. A server's weekly history and a CDN's page are read on demand, once per session.
/// </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);
readonly Dictionary<string, List<InstanceWeek>> histories = new(StringComparer.OrdinalIgnoreCase);
readonly Dictionary<string, CdnDetail> cdns = 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;
/// <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);
}
public List<InstanceWeek> KnownHistory(string host) => host is not null ? histories.GetValueOrDefault(host) : null;
public CdnDetail KnownCdn(string key) => key is not null ? cdns.GetValueOrDefault(key) : null;
/// <summary>The server's weekly snapshots, newest first; empty when PrivaPub has none.</summary>
public async Task<List<InstanceWeek>> History(string host, CancellationToken cancellationToken = default)
{
if (host is null)
return [];
if (KnownHistory(host) is { } known)
return known;
using var response = await httpService.GetAnon(APIs.PrivaPub.GET_M_InstanceHistory(host), [$"weeks=52"], cancellationToken: cancellationToken);
var history = response.IsSuccessStatusCode ? await response.MastodonReadFromJsonAsync<List<InstanceWeek>>(cancellationToken) ?? [] : [];
histories[host] = history;
return history;
}
/// <summary>A CDN's servers and its weeks, or null when PrivaPub does not know it.</summary>
public async Task<CdnDetail> Cdn(string key, CancellationToken cancellationToken = default)
{
if (key is null)
return null;
if (KnownCdn(key) is { } known)
return known;
using var response = await httpService.GetAnon(APIs.PrivaPub.GET_M_Cdn(key), [$"weeks=12"], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
var cdn = await response.MastodonReadFromJsonAsync<CdnDetail>(cancellationToken);
if (cdn is not null)
cdns[key] = cdn;
return cdn;
}
/// <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?.Cdn is { Length: > 0 } cdn)
return new(0, 0, LocationSource.Cdn, cdn, 0, geo.CdnDomain ?? cdn.ToLowerInvariant(), geo.BeforeCdn);
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);
}
}
}
}