using System.Text.Json.Nodes;
using Blazored.LocalStorage;
using decePubClient.Extensions;
using decePubClient.Helpers;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
namespace decePubClient.Services
{
///
/// 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.
///
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 servers = new(StringComparer.OrdinalIgnoreCase);
bool loaded;
string selfHost;
///
/// 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.
///
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 Self(CancellationToken cancellationToken = default)
{
await Resolve([SelfHost], cancellationToken);
return Known(SelfHost);
}
/// Reads the servers not known yet (or known for too long) from PrivaPub.
public async Task Resolve(IEnumerable 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>(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);
}
/// Places each post; nearYou is the reader's position, for located posts.
public void Locate(IEnumerable 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;
}
///
/// 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 ("日本").
///
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(out var number) ? number : null;
}
async Task Load(CancellationToken cancellationToken)
{
if (loaded)
return;
loaded = true;
try
{
foreach (var server in await localStorage.GetItemAsync>(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);
}
}
}
}