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
166 lines
5.7 KiB
C#
166 lines
5.7 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Net.Mail;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Text.Json.Serialization.Metadata;
|
|
|
|
using decePubClient.Models;
|
|
using decePubClient.Services;
|
|
|
|
using PrivaPub.ClientModels;
|
|
|
|
namespace decePubClient.Helpers
|
|
{
|
|
public static class SUtility
|
|
{
|
|
public const string dateTimeFormat = "dd/MM/yyyy";
|
|
public const string timeFormat = "HH:mm";
|
|
public const string dateTimeFormatExt = "dd/MM/yyyy HH:mm";
|
|
|
|
/// <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 /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);
|
|
|
|
public static HttpResponseMessage DefaultTaskCancelled() =>
|
|
ClassifiedFailure(FailureCodes.Cancelled, DefaultTaskCancelledResult.ErrorMessage, HttpStatusCode.Gone);
|
|
|
|
public static HttpResponseMessage DefaultUnauthorized(string message) =>
|
|
ClassifiedFailure(FailureCodes.Unauthorized, message, HttpStatusCode.Unauthorized);
|
|
|
|
/// <summary>A response carrying an invalid WebResult, tagged with FailureHeader so ReadWebResult reads it as is.</summary>
|
|
public static HttpResponseMessage ClassifiedFailure(string failureCode, string message, HttpStatusCode statusCode)
|
|
{
|
|
var content = JsonContent.Create(new WebResult().Invalidate(message, (int)statusCode, failureCode), options: DefaultSerializer);
|
|
content.Headers.Add(FailureHeader, failureCode);
|
|
return new(statusCode) { Content = content };
|
|
}
|
|
|
|
/// <summary>The value a cancelled read returns: default for value types, an empty list for list interfaces.</summary>
|
|
public static T CancellationDefault<T>()
|
|
{
|
|
var type = typeof(T);
|
|
if (type.IsValueType)
|
|
return default;
|
|
if (type.IsInterface && type.IsGenericType &&
|
|
type.GetGenericTypeDefinition() is var definition &&
|
|
(definition == typeof(IEnumerable<>) || definition == typeof(ICollection<>) || definition == typeof(IList<>) ||
|
|
definition == typeof(IReadOnlyCollection<>) || definition == typeof(IReadOnlyList<>)))
|
|
return (T)Activator.CreateInstance(typeof(List<>).MakeGenericType(type.GetGenericArguments()[0]));
|
|
if (type.IsInterface || type.IsAbstract || type.GetConstructor(Type.EmptyTypes) is null)
|
|
return default;
|
|
return Activator.CreateInstance<T>();
|
|
}
|
|
|
|
#region Toasts
|
|
|
|
/// <summary>Shows a failed result: connectivity failures as warnings, the rest as errors; cancellations not at all.</summary>
|
|
public static void AddResponseError(this ToastService toast, WebResult result)
|
|
{
|
|
if (result.StatusCode is (int)HttpStatusCode.Gone)
|
|
return;
|
|
|
|
if (FailureCodes.IsConnectivity(result.ErrorCode))
|
|
{
|
|
toast.Add(ToastSeverity.Warning, result.ErrorMessage);
|
|
return;
|
|
}
|
|
|
|
toast.Add(ToastSeverity.Error, $"{result.StatusCode} - {result.ErrorMessage}");
|
|
}
|
|
|
|
public static void AddResponseError(this ToastService toast, string message) =>
|
|
toast.Add(ToastSeverity.Error, message);
|
|
|
|
public static void AddResponseSuccess(this ToastService toast, string message) =>
|
|
toast.Add(ToastSeverity.Success, message, TimeSpan.FromSeconds(5));
|
|
|
|
public static void AddWarning(this ToastService toast, string message) =>
|
|
toast.Add(ToastSeverity.Warning, message);
|
|
|
|
public static void AddException(this ToastService toast, Exception exception) =>
|
|
toast.Add(ToastSeverity.Error, exception.Message);
|
|
|
|
#endregion
|
|
|
|
public static T IfTrueThen<T>(bool conditionIsTrue, T ifTrue, T ifFalse = default) =>
|
|
conditionIsTrue ? ifTrue : ifFalse;
|
|
|
|
public static bool TimeBetween(this DateTime datetime, TimeSpan start, TimeSpan end)
|
|
{
|
|
// convert datetime to a TimeSpan
|
|
var now = datetime.TimeOfDay;
|
|
// see if start comes before end
|
|
if (start < end)
|
|
return start <= now && now <= end;
|
|
// start is after end, so do the inverse comparison
|
|
return !(end < now && now < start);
|
|
}
|
|
|
|
public static string GetFileIcon(string fileName)
|
|
{
|
|
switch (Path.GetExtension(fileName))
|
|
{
|
|
case ".odp":
|
|
case ".pptx":
|
|
return "file-powerpoint";
|
|
|
|
case ".ods":
|
|
case ".xlsx":
|
|
return "file-excel";
|
|
|
|
case ".odt":
|
|
case ".docx":
|
|
return "file-word";
|
|
|
|
case ".pdf":
|
|
return "file-pdf";
|
|
|
|
case ".jpg":
|
|
case ".jpeg":
|
|
case ".png":
|
|
return "file-image";
|
|
|
|
default:
|
|
return "file";
|
|
}
|
|
}
|
|
|
|
public static bool IsValidEmail(string email)
|
|
{
|
|
try
|
|
{
|
|
var addr = new MailAddress(email);
|
|
return addr.Address == email;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|