Files
decePubClient/Services/RequestStats.cs
T
thepraandClaude Opus 5.5 827ecb770e 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
2026-10-04 11:03:14 +02:00

71 lines
2.3 KiB
C#

using System.Text.RegularExpressions;
namespace decePubClient.Services
{
/// <summary>One request HttpService sent, for the nerd stats.</summary>
public sealed record RequestStat(DateTime At, string Method, string Route, int Status, double DurationMs, long? Bytes,
int? RateLimitRemaining, DateTime? RateLimitReset);
/// <summary>
/// The last requests HttpService sent (a ring of Capacity entries), with route templates instead of ids. It holds no
/// bodies and no tokens: method, route, status, duration, size and PrivaPub's rate limit headers only.
/// </summary>
public partial class RequestStats
{
public const int Capacity = 200;
readonly RequestStat[] ring = new RequestStat[Capacity];
int next;
int count;
public event Action Recorded;
public int Count => count;
public void Record(RequestStat stat)
{
lock (ring)
{
ring[next] = stat;
next = (next + 1) % Capacity;
count = Math.Min(count + 1, Capacity);
}
Recorded?.Invoke();
}
/// <summary>The recorded requests, newest first.</summary>
public List<RequestStat> Latest(int take = Capacity)
{
lock (ring)
{
var latest = new List<RequestStat>(Math.Min(take, count));
for (var i = 1; i <= Math.Min(take, count); i++)
latest.Add(ring[(next - i + Capacity) % Capacity]);
return latest;
}
}
/// <summary>The newest request to a route template, or null.</summary>
public RequestStat LastOf(string route) => Latest().FirstOrDefault(stat => stat.Route == route);
/// <summary>The mean duration of the successful requests recorded, in milliseconds.</summary>
public double? AverageMs()
{
var answered = Latest().Where(stat => stat.Status is >= 200 and < 400).ToList();
return answered.Count == 0 ? null : answered.Average(stat => stat.DurationMs);
}
/// <summary>The route without its query, with ids replaced by :id, so requests to the same endpoint group.</summary>
public static string Template(string uri)
{
var path = uri?.Split('?', 2)[0] ?? string.Empty;
if (Uri.TryCreate(path, UriKind.Absolute, out var absolute))
path = absolute.AbsolutePath.TrimStart('/');
return Id().Replace(path, "/:id$1");
}
[GeneratedRegex(@"/(?:[0-9a-f]{24}|[0-9a-f]{32}|\d{6,}|[0-9A-Za-z_-]{20,})(/|$)")]
private static partial Regex Id();
}
}